diff --git a/.changeset/zero-rust-hidden-delay.md b/.changeset/zero-rust-hidden-delay.md new file mode 100644 index 0000000000..13368715d6 --- /dev/null +++ b/.changeset/zero-rust-hidden-delay.md @@ -0,0 +1,5 @@ +--- +'zero-rust': minor +--- + +`ZeroOptions::hidden_disconnect_delay_ms` now defaults to 5 minutes, as JS's `hiddenTabDisconnectDelay` does, instead of 0: after `Zero::set_visible(false)`, the client stays connected for 5 minutes before it disconnects. An app that wants the old behavior, such as a mobile app the system suspends in the background, sets `hidden_disconnect_delay_ms: 0.0`. diff --git a/rust/PARITY.md b/rust/PARITY.md index 26e0546bc6..aa93b04957 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -78,12 +78,14 @@ factory. | `slowMaterializeThreshold` | `slow_materialize_threshold_ms` (5,000, as in JS) | | `maxRecentQueries` (deprecated) | not ported: JS keeps no recent queries by default either | -Two defaults differ from JS. `storage` is `ZeroStorage::Memory`, since -SQLite is an opt-in feature, where JS keeps its data in IndexedDB. -`hidden_disconnect_delay_ms` is 0 where JS waits 5 minutes, as in Swift: -a native app is suspended soon after it goes to the background, so the -client disconnects as soon as the app says it is hidden. A desktop app -whose window can stay hidden for long can set JS's 300,000. +One default differs from JS, on purpose: `storage` is +`ZeroStorage::Memory`, where JS keeps its data in IndexedDB. SQLite +storage is the opt-in `sqlite` feature, so the default build has no +third-party dependencies, and an app chooses where its replica lives +(`ZeroStorage::Sqlite { directory }`); an app that wants its data across +restarts sets it. `hidden_disconnect_delay_ms` is JS's 5 minutes; a mobile +app, which the system suspends soon after it goes to the background, can +set 0 to disconnect as soon as it says it is hidden, as Swift does. ### The client diff --git a/rust/zero-client/src/client/engine.rs b/rust/zero-client/src/client/engine.rs index 26c3ccb217..1fc93c7c08 100644 --- a/rust/zero-client/src/client/engine.rs +++ b/rust/zero-client/src/client/engine.rs @@ -207,7 +207,10 @@ pub struct ZeroOptions { /// Where messages are logged. Defaults to [`StderrLogSink`]. pub log_sink: Option>, /// How long the app is hidden before the client disconnects - /// (`hiddenTabDisconnectDelay`); see [`Zero::set_visible`]. + /// (`hiddenTabDisconnectDelay`); see [`Zero::set_visible`]. 5 minutes + /// by default, as in JS. A mobile app, which the system suspends soon + /// after it goes to the background, can set 0 to disconnect as soon as + /// it is hidden. /// /// [`Zero::set_visible`]: crate::client::Zero::set_visible pub hidden_disconnect_delay_ms: f64, @@ -249,7 +252,7 @@ impl Default for ZeroOptions { on_storage_failure: None, log_level: LogLevel::Warn, log_sink: None, - hidden_disconnect_delay_ms: 0.0, + hidden_disconnect_delay_ms: 300_000.0, get_traceparent: None, batch_view_updates: None, slow_materialize_threshold_ms: 5_000.0, diff --git a/rust/zero-client/tests/visibility.rs b/rust/zero-client/tests/visibility.rs new file mode 100644 index 0000000000..1eef746b79 --- /dev/null +++ b/rust/zero-client/tests/visibility.rs @@ -0,0 +1,71 @@ +//! `ZeroOptions::default()` waits JS's 5 minutes (`hiddenTabDisconnectDelay`) +//! after `set_visible(false)` before it disconnects. The conformance vectors +//! set the delay themselves, so they don't check the default. + +use std::rc::Rc; +use std::sync::Arc; +use zero_client::client::{ConnectionState, Zero, ZeroOptions}; +use zero_client::json; +use zero_client::schema::Schema; +use zero_client::testing::{ManualScheduler, MockSocketFactory, RecordingLogSink}; + +#[test] +fn a_hidden_client_disconnects_after_five_minutes_by_default() { + let schema = Schema::from_json( + &json::parse( + r#"{"tables": {"issue": {"name": "issue", "primaryKey": ["id"], + "columns": {"id": {"type": "string"}}}}}"#, + ) + .unwrap(), + ) + .unwrap(); + let scheduler = ManualScheduler::new(1_700_000_000_000.0); + let factory = MockSocketFactory::new(); + let options = ZeroOptions { + cache_url: Some("http://zero.test".into()), + user_id: Some("u1".into()), + log_sink: Some(Rc::new(RecordingLogSink::default())), + ..ZeroOptions::default() + }; + let zero: Zero = Zero::new( + Arc::new(schema), + (), + vec![], + options, + scheduler.clone(), + Some(factory.clone()), + ) + .unwrap(); + let settle = || { + zero.dispatch(|| {}); + scheduler.run_due_timers(); + }; + settle(); + let socket = factory.sockets().last().cloned().expect("a socket"); + socket.receive_text(r#"["connected",{"wsid":"w1"}]"#); + settle(); + assert_eq!(zero.connection().state(), ConnectionState::Connected); + + zero.set_visible(false); + settle(); + // Answer pings, so the connection stays up while the app is hidden. + let mut waited = 0.0; + while waited < 299_000.0 { + scheduler.advance(1_000.0); + waited += 1_000.0; + socket.receive_text(r#"["pong",{}]"#); + settle(); + } + assert_eq!(zero.connection().state(), ConnectionState::Connected); + + scheduler.advance(1_000.0); + settle(); + assert!( + matches!( + zero.connection().state(), + ConnectionState::Disconnected { .. } + ), + "{:?}", + zero.connection().state() + ); +}