Skip to main content

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}