frust_reactive/deep_link.rs
1//! Process-wide deep-link source (`app_links` semantics): a shell delivers a
2//! platform link (cold-start intent data, or a running app's
3//! `onNewIntent`/`openURLContexts`) via [`push_deep_link`]; app
4//! code reads the current state through [`deep_links`]/[`DeepLinks`] — the
5//! `frust` facade re-exports both as `frust::deep_links()`/
6//! `frust::DeepLinks`, so app code never names this crate directly.
7//!
8//! # `app_links` semantics
9//!
10//! Unlike the discontinued `uni_links` (an initial link fetched once, then a
11//! separate stream for subsequent links), `app_links`-style delivery treats
12//! the initial link as just the *first* element of one uniform stream: every
13//! link (cold-start and warm) is written to the same [`RwSignal`]
14//! ([`DeepLinks::latest`]), so a subscriber that only tracks `latest` sees
15//! both uniformly. [`DeepLinks::initial`] additionally snapshots the very
16//! first link ever pushed in this process — set at most once, readable any
17//! number of times — for callers (the router glue) that need
18//! cold-start precedence without setting up a subscription.
19//!
20//! **Documented limitation**: a push carries no "this is the cold-start link"
21//! flag, so `initial` is simply "whichever link arrived first" in this
22//! process. That is correct for the intended case (a real cold-start link
23//! always arrives before the first app rebuild, per the shell's
24//! queue-until-handle-exists contract), but a session with no real cold-start link whose
25//! first-ever push happens to race ahead of the first [`deep_links`] call
26//! would also see that push recorded as `initial`. Not a concern in practice
27//! given the shell init order.
28//!
29//! # Thread contract
30//!
31//! [`push_deep_link`] must be called on the UI thread — the one
32//! [`ReactiveRuntime::init`] ran on — mirroring `Executor::spawn_local`'s
33//! contract (see `frust-reactive::executor`): the mobile shells' native
34//! callbacks always run on the UI thread, so an off-thread call is a wiring
35//! bug, not a runtime-data condition, and panics with the same message
36//! convention `Executor::spawn_local` uses.
37//!
38//! A push that races ahead of [`ReactiveRuntime::init`] (an odd shell-
39//! ordering edge case — the documented shell flow queues platform-side until
40//! the native handle exists, so this should not happen through it) is
41//! **dropped with a logged warning** rather than panicking or buffering
42//! indefinitely: buffering would need to pick a bound and a flush point for a
43//! path that isn't expected to be exercised, and a bare drop can never lose
44//! the *cold-start* link specifically (that path is always delivered after
45//! init, once the native handle exists).
46
47use std::sync::atomic::{AtomicU64, Ordering};
48use std::sync::{Mutex, OnceLock};
49
50use reactive_graph::signal::RwSignal;
51use reactive_graph::traits::Set;
52
53use crate::ReactiveRuntime;
54use crate::executor::is_ui_thread;
55
56/// One delivered deep link: the raw platform-provided URL/location string
57/// (e.g. `myapp://profile/42`), unparsed — turning it into a route is the
58/// router layer's job (the router/deep-link glue), not this crate's.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct DeepLink {
61 pub url: String,
62 /// Monotonic per-process delivery counter; compare sequences, not URLs, to
63 /// apply each delivery exactly once — two identical URLs delivered twice
64 /// are two deliveries.
65 pub sequence: u64,
66}
67
68impl DeepLink {
69 /// Wrap a raw URL/location string. Auto-assigns the next monotonic sequence.
70 pub fn new(url: impl Into<String>) -> Self {
71 let sequence = SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1;
72 Self {
73 url: url.into(),
74 sequence,
75 }
76 }
77}
78
79/// Process-wide monotonic sequence counter: incremented on each `DeepLink::new`.
80static SEQUENCE: AtomicU64 = AtomicU64::new(0);
81
82/// The app-facing deep-link read surface (see the module docs' `app_links`
83/// semantics). Obtained via [`deep_links`] (`frust::deep_links()` at the
84/// facade).
85#[derive(Clone)]
86pub struct DeepLinks {
87 /// The first link ever pushed in this process, if any — a plain
88 /// snapshot taken when [`deep_links`] is called, not itself reactive.
89 /// The underlying value is set at most once (idempotent), so repeated
90 /// calls to [`deep_links`] see the same `initial` once it exists.
91 pub initial: Option<String>,
92 /// The most recently pushed link — cold-start or warm, uniformly (see
93 /// the module docs). Use [`DeepLink::sequence`] to deduplicate deliveries.
94 /// Read/track it with the `Get`/`Track` traits (`frust::{Get, Track}`)
95 /// the same way any other `RwSignal` is read.
96 pub latest: RwSignal<Option<DeepLink>>,
97}
98
99/// The process-wide deep-link slot: the first-ever-pushed snapshot plus the
100/// live `latest` signal. Lazily created (mirroring [`ReactiveRuntime`]'s own
101/// process-wide, lazily-installed static) on first access once
102/// [`ReactiveRuntime`] exists, without needing to modify
103/// `ReactiveRuntime::init` itself.
104struct Slot {
105 initial: Mutex<Option<String>>,
106 latest: RwSignal<Option<DeepLink>>,
107}
108
109static SLOT: OnceLock<Slot> = OnceLock::new();
110
111/// Returns the process-wide slot, creating it (under the reactive root
112/// [`Owner`](reactive_graph::owner::Owner)) on first access.
113///
114/// # Panics
115///
116/// Panics if [`ReactiveRuntime::init`] has not run yet — reading or pushing a
117/// deep link before the reactive runtime exists across every other path
118/// (`push_deep_link`'s pre-init case is handled separately, before this is
119/// ever called) is a genuine wiring bug: the caller must initialize the
120/// runtime first.
121fn slot() -> &'static Slot {
122 SLOT.get_or_init(|| {
123 let rt = ReactiveRuntime::get().expect(
124 "frust-reactive: deep_links() was called before ReactiveRuntime::init — an app \
125 must run under the Frust facade's entry point (which initializes the reactive \
126 runtime) before reading deep links",
127 );
128 Slot {
129 initial: Mutex::new(None),
130 latest: rt.with_owner(|| RwSignal::new(None)),
131 }
132 })
133}
134
135/// Test-only: clears the recorded `initial` URL so a test can observe a first
136/// push regardless of which tests ran earlier in the same process. The slot is
137/// shared process-wide, so callers must hold `WAKER_TEST_LOCK`.
138#[cfg(test)]
139fn reset_initial_for_test() {
140 *slot()
141 .initial
142 .lock()
143 .expect("frust-reactive: deep_link initial mutex poisoned") = None;
144}
145
146/// Deliver a platform deep link (cold-start or warm) into the process-wide
147/// source. Called by a shell (the Android/iOS FFI glue) on the UI
148/// thread; app code never calls this directly.
149///
150/// The first call in a process snapshots its URL into
151/// [`DeepLinks::initial`] (idempotent — later calls do not overwrite it);
152/// every call (including the first) also writes [`DeepLinks::latest`], so a
153/// tracked reader observes both cold-start and warm links through the same
154/// signal (see the module docs' `app_links` semantics). Each delivered link
155/// is tagged with a monotonically increasing [`DeepLink::sequence`] — use it
156/// to deduplicate deliveries rather than comparing URL text.
157///
158/// # Panics
159///
160/// Panics if called off the UI thread (see the module docs' thread
161/// contract). A call before [`ReactiveRuntime::init`] does **not** panic — it
162/// is dropped with a logged warning (see the module docs).
163pub fn push_deep_link(url: impl Into<String>) {
164 let url = url.into();
165
166 if !is_ui_thread() {
167 panic!(
168 "frust-reactive: push_deep_link was called off the UI thread. Deep links can \
169 only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
170 is a wiring bug: route the platform delivery through the UI thread before pushing, \
171 the same contract `Executor::spawn_local` enforces."
172 );
173 }
174
175 if ReactiveRuntime::get().is_none() {
176 eprintln!(
177 "frust-reactive: push_deep_link(\"{url}\") dropped — ReactiveRuntime::init has \
178 not run yet. A shell should queue a link platform-side until its native handle \
179 exists; reaching this indicates an odd init-ordering race, not normal \
180 operation."
181 );
182 return;
183 }
184
185 let slot = slot();
186 {
187 let mut initial = slot
188 .initial
189 .lock()
190 .expect("frust-reactive: deep_link initial mutex poisoned");
191 if initial.is_none() {
192 *initial = Some(url.clone());
193 }
194 }
195 slot.latest.set(Some(DeepLink::new(url)));
196}
197
198/// The current deep-link read surface: a snapshot of [`DeepLinks::initial`]
199/// plus the live [`DeepLinks::latest`] signal. Call from a tracked context
200/// (e.g. inside `Component::build`) to observe subsequent pushes as they
201/// arrive.
202pub fn deep_links() -> DeepLinks {
203 let slot = slot();
204 let initial = slot
205 .initial
206 .lock()
207 .expect("frust-reactive: deep_link initial mutex poisoned")
208 .clone();
209 DeepLinks {
210 initial,
211 latest: slot.latest,
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::{FrameWaker, TrackedScope};
219 use reactive_graph::traits::{Get, GetUntracked};
220 use std::sync::Arc;
221 use std::sync::atomic::{AtomicUsize, Ordering};
222
223 /// A recording waker: an `Arc<AtomicUsize>` bumped once per `wake()`.
224 fn recording_waker() -> (FrameWaker, Arc<AtomicUsize>) {
225 let counter = Arc::new(AtomicUsize::new(0));
226 let seen = counter.clone();
227 let waker: FrameWaker = Arc::new(move || {
228 counter.fetch_add(1, Ordering::SeqCst);
229 });
230 (waker, seen)
231 }
232
233 /// Acceptance criterion (a): two pushes of the same URL yield sequences n
234 /// and n+1 with equal URLs.
235 #[test]
236 fn sequence_increments_on_identical_urls() {
237 let _guard = crate::WAKER_TEST_LOCK
238 .lock()
239 .unwrap_or_else(|e| e.into_inner());
240
241 let (waker, _) = recording_waker();
242 let _rt = ReactiveRuntime::init(waker);
243
244 let url = "frust-test://identical".to_string();
245 push_deep_link(url.clone());
246 let link1 = deep_links().latest.get_untracked().unwrap();
247
248 push_deep_link(url.clone());
249 let link2 = deep_links().latest.get_untracked().unwrap();
250
251 // Same URL, but strictly increasing sequences
252 assert_eq!(link1.url, link2.url);
253 assert_eq!(link1.sequence + 1, link2.sequence);
254 }
255
256 /// Acceptance criterion (b): `initial` (if constructed through
257 /// DeepLink::new) has a sequence lower than any later push.
258 #[test]
259 fn initial_sequence_lower_than_later_push() {
260 let _guard = crate::WAKER_TEST_LOCK
261 .lock()
262 .unwrap_or_else(|e| e.into_inner());
263
264 let (waker, _) = recording_waker();
265 let _rt = ReactiveRuntime::init(waker);
266
267 let url1 = "frust-test://initial".to_string();
268 push_deep_link(url1.clone());
269 let links1 = deep_links();
270 let initial_seq = links1
271 .latest
272 .get_untracked()
273 .expect("initial push should have produced a link")
274 .sequence;
275
276 let url2 = "frust-test://later".to_string();
277 push_deep_link(url2);
278 let links2 = deep_links();
279 let later_seq = links2
280 .latest
281 .get_untracked()
282 .expect("second push should have produced a link")
283 .sequence;
284
285 assert!(
286 initial_seq < later_seq,
287 "initial sequence {} should be lower than later sequence {}",
288 initial_seq,
289 later_seq
290 );
291 }
292
293 /// Acceptance criterion (c): sequences are strictly increasing across
294 /// mixed URLs.
295 #[test]
296 fn sequences_strictly_increasing_across_mixed_urls() {
297 let _guard = crate::WAKER_TEST_LOCK
298 .lock()
299 .unwrap_or_else(|e| e.into_inner());
300
301 let (waker, _) = recording_waker();
302 let _rt = ReactiveRuntime::init(waker);
303
304 let urls = vec![
305 "frust-test://a",
306 "frust-test://b",
307 "frust-test://a", // repeat
308 "frust-test://c",
309 "frust-test://b", // repeat
310 ];
311
312 let mut sequences = Vec::new();
313 for url in urls {
314 push_deep_link(url);
315 let seq = deep_links()
316 .latest
317 .get_untracked()
318 .expect("push should produce a link")
319 .sequence;
320 sequences.push(seq);
321 }
322
323 // Verify strictly increasing
324 for i in 1..sequences.len() {
325 assert!(
326 sequences[i - 1] < sequences[i],
327 "sequence {} should be strictly less than {}",
328 sequences[i - 1],
329 sequences[i]
330 );
331 }
332 }
333
334 /// Original waker/wake-bridge contract test, adapted for the sequence field.
335 #[test]
336 fn deep_link_push_and_wake_bridge() {
337 let _guard = crate::WAKER_TEST_LOCK
338 .lock()
339 .unwrap_or_else(|e| e.into_inner());
340
341 let (waker, wakes) = recording_waker();
342 let _rt = ReactiveRuntime::init(waker);
343
344 // Other tests push links into the same process-wide slot, so start
345 // from a state where no link has been recorded as `initial`.
346 reset_initial_for_test();
347
348 // Criterion: push before any tracked read — a late subscriber sees it
349 // immediately via BOTH the `initial` snapshot and the live `latest`
350 // signal (app_links semantics: cold-start and warm links flow through
351 // the same source).
352 let url1 = "frust-test://a/1".to_string();
353 push_deep_link(url1.clone());
354
355 let links = deep_links();
356 assert_eq!(links.initial.as_deref(), Some(url1.as_str()));
357 let link1 = links
358 .latest
359 .get_untracked()
360 .expect("should have pushed a link");
361 assert_eq!(link1.url, url1);
362 assert!(link1.sequence > 0, "sequence should be assigned");
363
364 // A tracked scope reading `latest` after the push observes the
365 // already-pushed link with no wake needed — an ordinary read.
366 let scope = TrackedScope::new();
367 let seen = scope.track(|| deep_links().latest.get());
368 let seen_link = seen.expect("should have a link");
369 assert_eq!(seen_link.url, url1);
370 assert_eq!(
371 seen_link.sequence, link1.sequence,
372 "sequence should be stable"
373 );
374 assert!(!scope.is_dirty(), "a fresh track starts clean");
375
376 // Criterion: push after a tracked read fires the signal-write -> wake
377 // contract — the tracked scope re-dirties and the waker fires exactly
378 // once (coalesced).
379 let before = wakes.load(Ordering::SeqCst);
380 let url2 = "frust-test://b/2".to_string();
381 push_deep_link(url2.clone());
382 assert!(
383 scope.is_dirty(),
384 "push_deep_link must dirty a scope tracking `latest`"
385 );
386 assert_eq!(
387 wakes.load(Ordering::SeqCst) - before,
388 1,
389 "a push must fire the waker exactly once"
390 );
391
392 // `initial` is set once and never overwritten, even though `latest`
393 // has since moved on to the second link.
394 let links2 = deep_links();
395 assert_eq!(
396 links2.initial.as_deref(),
397 Some(url1.as_str()),
398 "initial is set once and stays"
399 );
400 let link2 = links2
401 .latest
402 .get_untracked()
403 .expect("should have pushed a second link");
404 assert_eq!(link2.url, url2);
405 assert!(
406 link2.sequence > link1.sequence,
407 "second sequence should be higher"
408 );
409 }
410
411 /// Criterion: the UI-thread contract is enforced (mirrors
412 /// `Executor::spawn_local`'s off-thread panic). Also serializes on the
413 /// waker lock since `ReactiveRuntime::init` swaps the process-wide waker.
414 #[test]
415 #[should_panic(expected = "wiring bug")]
416 fn push_off_ui_thread_panics() {
417 let _guard = crate::WAKER_TEST_LOCK
418 .lock()
419 .unwrap_or_else(|e| e.into_inner());
420 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
421
422 std::thread::spawn(|| {
423 push_deep_link("frust-test://off-thread");
424 })
425 .join()
426 .unwrap_or_else(|e| std::panic::resume_unwind(e));
427 }
428}