Skip to main content

topcoat_runtime/
signal.rs

1use std::{any::TypeId, collections::HashMap, panic::Location, sync::Arc};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
4use topcoat_core::{
5    context::{Cx, identity, try_request_context},
6    identity::{Identity, SiteKey},
7};
8use topcoat_view::{HoistKey, hoist, hoist_once};
9
10use crate::{Surrogate, Surrogated};
11
12/// The identity of a signal, shared by the server and the browser runtime.
13///
14/// The context's identity and the `signal` call's location determine the id.
15/// The same invocation therefore keeps its id across renders.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct SignalId(u128);
18
19impl SignalId {
20    /// Derives the id at `location` below the context's checked identity.
21    pub(crate) fn derive(identity: Identity, location: &Location<'_>) -> Self {
22        Self(identity.child(SiteKey::from_location(location)).hash())
23    }
24}
25
26impl std::fmt::Display for SignalId {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{:032x}", self.0)
29    }
30}
31
32impl Serialize for SignalId {
33    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
34        serializer.collect_str(self)
35    }
36}
37
38impl<'de> Deserialize<'de> for SignalId {
39    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
40        struct Visitor;
41
42        impl serde::de::Visitor<'_> for Visitor {
43            type Value = SignalId;
44
45            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46                f.write_str("a hex signal id")
47            }
48
49            fn visit_str<E: serde::de::Error>(self, hex: &str) -> Result<Self::Value, E> {
50                u128::from_str_radix(hex, 16)
51                    .map(SignalId)
52                    .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(hex), &self))
53            }
54        }
55
56        deserializer.deserialize_str(Visitor)
57    }
58}
59
60/// Signal values sent by the client for a server render, keyed by signal id.
61///
62/// Register this in the request context to restore signals from client
63/// state. [`signal`] uses the matching value if it fits the signal's type,
64/// or computes an initial value otherwise. All restored values are untrusted.
65#[derive(Debug, Default, Deserialize)]
66#[serde(transparent)]
67pub struct SignalValues(HashMap<SignalId, serde_json::Value>);
68
69impl SignalValues {
70    /// The value carried for `id`, if any.
71    fn get(&self, id: SignalId) -> Option<&serde_json::Value> {
72        self.0.get(&id)
73    }
74}
75
76/// Reactive state shared by browser expressions.
77///
78/// Create a signal with [`signal`] during rendering. Runtime expressions
79/// can read and write it. When its value changes, browser expressions
80/// that read it run again. Cloning a signal is cheap and refers to the
81/// same state.
82///
83/// On the server, [`get`](Self::get) and [`read`](Self::read) track the
84/// signal as a dependency. A change re-renders the enclosing shard, or the
85/// page if there is no shard. Use [`get_untracked`](Self::get_untracked) or
86/// [`read_untracked`](Self::read_untracked) to read without this dependency.
87/// **Validate values read on the server because the client can supply them.**
88#[derive(Debug)]
89pub struct Signal<T> {
90    id: SignalId,
91    /// Shared between clones, so capturing a signal never copies its value.
92    value: Arc<T>,
93}
94
95impl<T> Signal<T> {
96    #[inline]
97    pub(crate) fn new(id: SignalId, value: T) -> Self {
98        Self {
99            id,
100            value: Arc::new(value),
101        }
102    }
103
104    pub(crate) fn id(&self) -> SignalId {
105        self.id
106    }
107
108    /// Borrows the current value, tracking the signal as a dependency of
109    /// the body reading it.
110    ///
111    /// A change re-renders the enclosing shard, or the page if there is no
112    /// shard. Repeated reads create only one dependency.
113    ///
114    /// # Panics
115    ///
116    /// Panics outside an active rendering scope or runtime expression.
117    #[must_use]
118    #[track_caller]
119    pub fn read(&self) -> &T {
120        self.track();
121        &self.value
122    }
123
124    /// Borrows the current value without tracking the signal.
125    ///
126    /// Outside runtime expressions, this creates no dependency and needs
127    /// no rendering scope.
128    #[must_use]
129    pub fn read_untracked(&self) -> &T {
130        crate::expr::mark_signal_read();
131        &self.value
132    }
133
134    /// Hoists the marker that makes the enclosing body's content depend on
135    /// this signal.
136    #[track_caller]
137    fn track(&self) {
138        // Rust fallbacks inside expr! follow the client-reactive path too.
139        if crate::expr::mark_signal_read() {
140            return;
141        }
142        let id = self.id;
143        hoist_once(
144            HoistKey::new((TypeId::of::<SignalId>(), id)),
145            move |parts| {
146                parts.push_comment(|comment| {
147                    comment
148                        .push_promoted_str_unescaped(&"::topcoat::dep(\"")
149                        .push_string_unescaped(id.to_string())
150                        .push_promoted_str_unescaped(&"\")");
151                });
152            },
153        );
154    }
155
156    /// Serializes the declaration the browser runtime creates the signal
157    /// from: its id and initial value.
158    fn declaration(&self) -> String
159    where
160        T: SignalValue,
161    {
162        #[derive(Serialize)]
163        struct Declaration<'a, V>
164        where
165            V: ?Sized,
166        {
167            t: &'static str,
168            id: SignalId,
169            v: &'a V,
170        }
171
172        let value = self.value.surrogate();
173        let declaration = Declaration {
174            t: "signal",
175            id: self.id,
176            v: &value,
177        };
178        serde_json::to_string(&declaration).expect("failed to serialize signal declaration")
179    }
180}
181
182impl<T> Signal<T>
183where
184    T: Clone,
185{
186    /// Clones the current value, tracking the signal as a dependency of
187    /// the body reading it.
188    ///
189    /// Uses the same tracking rules as [`read`](Self::read).
190    ///
191    /// # Panics
192    ///
193    /// Panics outside an active rendering scope or runtime expression.
194    #[must_use]
195    #[track_caller]
196    pub fn get(&self) -> T {
197        self.track();
198        T::clone(&self.value)
199    }
200
201    /// Clones the current value without tracking the signal.
202    ///
203    /// Uses the same tracking rules as [`read_untracked`](Self::read_untracked).
204    #[must_use]
205    pub fn get_untracked(&self) -> T {
206        T::clone(self.read_untracked())
207    }
208}
209
210impl<T> Clone for Signal<T> {
211    fn clone(&self) -> Self {
212        Self {
213            id: self.id,
214            value: Arc::clone(&self.value),
215        }
216    }
217}
218
219/// A value that can be sent to the browser and restored from browser state.
220///
221/// Implemented automatically for runtime types whose surrogates support
222/// serialization and deserialization.
223pub trait SignalValue: Sized {
224    /// The serializable surrogate of a borrowed value.
225    type Surrogate<'a>: Serialize
226    where
227        Self: 'a;
228
229    /// Borrows the value as its surrogate.
230    fn surrogate(&self) -> Self::Surrogate<'_>;
231
232    /// Reads a value back from the surrogate a client sent, or `None` if
233    /// the surrogate does not fit this type.
234    fn from_value(value: &serde_json::Value) -> Option<Self>;
235}
236
237impl<T> SignalValue for T
238where
239    T: Surrogated,
240    T::Surrogate: DeserializeOwned,
241    for<'a> &'a T: Surrogated,
242    for<'a> <&'a T as Surrogated>::Surrogate: Serialize,
243{
244    type Surrogate<'a>
245        = <&'a T as Surrogated>::Surrogate
246    where
247        Self: 'a;
248
249    fn surrogate(&self) -> Self::Surrogate<'_> {
250        self.into_surrogate()
251    }
252
253    fn from_value(value: &serde_json::Value) -> Option<Self> {
254        T::Surrogate::deserialize(value)
255            .ok()
256            .map(Surrogate::into_real)
257    }
258}
259
260/// Creates a signal holding the value `init` returns.
261///
262/// The initial render calls `init` and sends its value to the browser.
263/// Capture the signal in runtime expressions or pass it to components.
264/// Clones refer to the same signal.
265///
266/// When a render resumes browser state, a matching value replaces `init`.
267/// This preserves signal state across renders. **Validate values read on
268/// the server** because the client can supply any value that fits the type.
269///
270/// ```rust
271/// use topcoat::{Result, context::Cx, runtime::signal, view::*};
272///
273/// #[component]
274/// async fn counter(cx: &Cx) -> Result<impl View> {
275///     let count = signal(cx, || 0usize);
276///
277///     Ok(view! {
278///         <button @click=$(|_e| count.increment())>"+1"</button>
279///         <p>"Count: " $(count.get())</p>
280///     })
281/// }
282/// ```
283///
284/// A signal's identity comes from `cx` and the location of this call.
285/// Components receive their invocation's context automatically. Components
286/// inside a template loop need `#[key(...)]` on that loop to distinguish
287/// their signals.
288///
289/// For ordinary helpers called more than once, pass a context derived with
290/// [`Cx::keyed`]. Separate `cx.keyed(())` calls distinguish source locations;
291/// repeated calls at one location need distinct keys. Template loops do not
292/// rebind context variables used in ordinary Rust expressions.
293///
294/// # Panics
295///
296/// Panics if `cx` belongs to a memoized call or carries an ambiguous identity,
297/// or if no view is collecting signal declarations. A spawned task must
298/// establish its own rendering scope before creating signals.
299#[track_caller]
300pub fn signal<T>(cx: &Cx, init: impl FnOnce() -> T) -> Signal<T>
301where
302    T: SignalValue,
303{
304    let id = SignalId::derive(identity(cx), Location::caller());
305    let value = try_request_context::<SignalValues>(cx)
306        .and_then(|values| values.get(id))
307        .and_then(T::from_value)
308        .unwrap_or_else(init);
309    let signal = Signal::new(id, value);
310    let declaration = signal.declaration();
311    hoist(move |parts| {
312        parts.push_comment(|comment| {
313            // The declaration carries untrusted application data, so it is
314            // escaped like any other comment body rather than pushed raw.
315            comment
316                .push_promoted_str_unescaped(&"::topcoat::signal(")
317                .push_string(declaration)
318                .push_promoted_str_unescaped(&")");
319        });
320    });
321    signal
322}
323
324#[cfg(test)]
325mod tests {
326    use std::{
327        panic::{AssertUnwindSafe, catch_unwind},
328        pin::pin,
329        sync::{
330            Arc, OnceLock,
331            atomic::{AtomicBool, Ordering},
332        },
333        task::{Context, Poll, Waker},
334    };
335
336    use topcoat::view::{HoistView, ViewExt, internal::ThenView, view};
337    use topcoat_core::context::with_identity;
338
339    use super::*;
340
341    const SITE_A: SiteKey = SiteKey::new(file!(), line!(), column!(), 0);
342    const SITE_B: SiteKey = SiteKey::new(file!(), line!(), column!(), 0);
343
344    /// Drives a future that never yields to completion.
345    fn block_on<F: Future>(future: F) -> F::Output {
346        let mut future = pin!(future);
347        let mut cx = Context::from_waker(Waker::noop());
348        loop {
349            if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
350                return output;
351            }
352        }
353    }
354
355    /// Renders a body creating one string signal, whose value is read back
356    /// into the content.
357    fn render_with_signal(value: &'static str) -> String {
358        let cx = &Cx::default();
359        let view = HoistView::new(ThenView::new(async move {
360            let signal = signal(cx, || String::from(value));
361            Ok(view! { cx => <p>(signal.read_untracked())</p> })
362        }));
363        block_on(view.single()).unwrap().render(cx)
364    }
365
366    /// Renders a body creating one number signal and reading it with
367    /// `read`, returning the content and the signal's dependency marker.
368    fn render_reading(read: impl Fn(&Signal<String>) + Send + 'static) -> (String, String) {
369        let cx = &Cx::default();
370        let id = Arc::new(OnceLock::new());
371        let out = Arc::clone(&id);
372        let view = HoistView::new(ThenView::new(async move {
373            let signal = signal(cx, || String::from("x"));
374            read(&signal);
375            out.set(signal.id()).unwrap();
376            Ok(view! { cx => <p></p> })
377        }));
378        let html = block_on(view.single()).unwrap().render(cx);
379        let marker = format!("<!--::topcoat::dep(\"{}\")-->", id.get().unwrap());
380        (html, marker)
381    }
382
383    /// Renders a body creating one signal under the component identity at
384    /// `site`, returning the signal's id.
385    fn signal_id_at(site: SiteKey) -> SignalId {
386        let cx = &with_identity(Cx::default(), Identity::ROOT.child(site));
387        let view = HoistView::new(ThenView::new(async move {
388            let signal = signal(cx, || 0.0_f64);
389            Ok(view! { cx => <p>(signal.id().to_string())</p> })
390        }));
391        let html = block_on(view.single()).unwrap().render(cx);
392        let start = html.find("<p>").unwrap() + 3;
393        let end = html.rfind("</p>").unwrap();
394        SignalId(u128::from_str_radix(&html[start..end], 16).unwrap())
395    }
396
397    #[test]
398    fn creating_a_signal_outside_a_body_panics() {
399        let cx = Cx::default();
400        let panic = catch_unwind(AssertUnwindSafe(|| signal(&cx, || 0.0_f64))).unwrap_err();
401        let message = panic.downcast::<&str>().expect("panics with a message");
402        assert!(message.contains("no view is collecting hoisted parts"));
403    }
404
405    #[test]
406    fn the_same_call_site_renders_the_same_id_every_time() {
407        assert_eq!(render_with_signal("x"), render_with_signal("x"));
408        assert_eq!(signal_id_at(SITE_A), signal_id_at(SITE_A));
409    }
410
411    #[test]
412    fn distinct_call_sites_render_distinct_ids() {
413        let cx = &Cx::default();
414        let view = HoistView::new(ThenView::new(async move {
415            let first = signal(cx, || 0.0_f64);
416            let second = signal(cx, || 0.0_f64);
417            assert_ne!(first.id(), second.id());
418            Ok(view! { cx => <p></p> })
419        }));
420        block_on(view.single()).unwrap();
421    }
422
423    #[test]
424    fn distinct_identities_render_distinct_ids() {
425        assert_ne!(signal_id_at(SITE_A), signal_id_at(SITE_B));
426    }
427
428    #[test]
429    fn an_ambiguous_identity_panics() {
430        let cx = with_identity(
431            Cx::default(),
432            Identity::ROOT.ambiguous_child(SITE_A, "`card` at src/a.rs:1"),
433        );
434        let panic = catch_unwind(AssertUnwindSafe(|| signal(&cx, || 0.0_f64))).unwrap_err();
435        let message = panic.downcast::<String>().expect("panics with a message");
436        assert!(message.contains("`card` at src/a.rs:1"), "{message}");
437    }
438
439    /// Renders a body creating one number signal with an initial value of
440    /// one, returning the signal's id and value and whether `init` ran.
441    fn number_signal(cx: &Cx) -> (SignalId, f64, bool) {
442        let seen = Arc::new(OnceLock::new());
443        let out = Arc::clone(&seen);
444        let view = HoistView::new(ThenView::new(async move {
445            let init_ran = AtomicBool::new(false);
446            let signal = signal(cx, || {
447                init_ran.store(true, Ordering::Relaxed);
448                1.0_f64
449            });
450            out.set((
451                signal.id(),
452                *signal.read_untracked(),
453                init_ran.load(Ordering::Relaxed),
454            ))
455            .unwrap();
456            Ok(view! { cx => <p></p> })
457        }));
458        block_on(view.single()).unwrap();
459        *seen.get().unwrap()
460    }
461
462    /// A context carrying `value` for the signal `id`.
463    fn cx_carrying(id: SignalId, value: serde_json::Value) -> Cx {
464        Cx::default().with(SignalValues(HashMap::from([(id, value)])))
465    }
466
467    #[test]
468    fn a_signal_resumes_from_the_value_the_request_carries() {
469        let (id, value, init_ran) = number_signal(&Cx::default());
470        assert_eq!((value, init_ran), (1.0, true));
471
472        let cx = cx_carrying(id, serde_json::json!(5.0));
473        assert_eq!(number_signal(&cx), (id, 5.0, false));
474    }
475
476    #[test]
477    fn context_keys_distinguish_repeated_helper_calls() {
478        fn keyed_number(key: u32) -> SignalId {
479            number_signal(&Cx::default().keyed(key)).0
480        }
481
482        let first = keyed_number(1);
483        let second = keyed_number(2);
484        assert_ne!(first, second);
485        assert_eq!(keyed_number(2), second);
486        assert_eq!(keyed_number(1), first);
487
488        let cx = Cx::default();
489        let first = number_signal(&cx.keyed(())).0;
490        let second = number_signal(&cx.keyed(())).0;
491        assert_ne!(first, second);
492    }
493
494    #[test]
495    #[should_panic(expected = "identity cannot be read inside memoized functions")]
496    fn memoized_functions_cannot_create_signals() {
497        let cx = Cx::default();
498        topcoat_core::context::memoize_cache(&cx)
499            .memoize(&cx, (), (), |cx, ()| signal(&cx.keyed(()), || 0.0));
500    }
501
502    #[test]
503    fn a_value_that_does_not_fit_the_signal_is_ignored() {
504        let (id, ..) = number_signal(&Cx::default());
505
506        let cx = cx_carrying(id, serde_json::json!("five"));
507        assert_eq!(number_signal(&cx), (id, 1.0, true));
508    }
509
510    #[test]
511    fn an_id_round_trips_through_json_as_hex() {
512        let id = SignalId(0x1234_abcd);
513        let json = serde_json::to_string(&id).unwrap();
514        assert_eq!(json, format!("\"{:032x}\"", 0x1234_abcd_u128));
515        assert_eq!(serde_json::from_str::<SignalId>(&json).unwrap(), id);
516        assert!(serde_json::from_str::<SignalId>("\"zz\"").is_err());
517    }
518
519    #[test]
520    fn the_declaration_renders_ahead_of_the_content() {
521        let html = render_with_signal("x");
522        assert!(html.starts_with("<!--::topcoat::signal("), "{html}");
523        assert!(html.ends_with("--><p>x</p>"), "{html}");
524    }
525
526    #[test]
527    fn payload_cannot_terminate_the_comment() {
528        // A value carrying `-->`, a quote, and an ampersand: the characters
529        // that could break out of the comment or corrupt its JSON payload.
530        let html = render_with_signal("a-->b\"c&d");
531
532        // The comment context escaped `>`, so the only `-->` left is the
533        // marker's own terminator; the payload cannot end the comment early.
534        assert_eq!(html.matches("-->").count(), 1, "{html}");
535        assert!(html.contains("--&gt;"), "{html}");
536        // The JSON's own quotes round-trip as entities the client decodes.
537        assert!(html.contains("&quot;"), "{html}");
538    }
539
540    #[test]
541    fn a_tracked_read_renders_a_dependency_marker_after_the_declaration() {
542        let (html, marker) = render_reading(|signal| {
543            assert_eq!(signal.get(), "x");
544        });
545        assert!(html.starts_with("<!--::topcoat::signal("), "{html}");
546        assert!(html.ends_with(&format!("{marker}<p></p>")), "{html}");
547    }
548
549    #[test]
550    fn repeated_reads_render_one_dependency_marker() {
551        let (html, marker) = render_reading(|signal| {
552            let _ = signal.get();
553            let _ = signal.read();
554            let _ = signal.get();
555        });
556        assert_eq!(html.matches(&marker).count(), 1, "{html}");
557    }
558
559    #[test]
560    fn untracked_reads_render_no_dependency_marker() {
561        let (html, _) = render_reading(|signal| {
562            assert_eq!(signal.get_untracked(), "x");
563            assert_eq!(signal.read_untracked(), "x");
564        });
565        assert!(!html.contains("::topcoat::dep("), "{html}");
566    }
567
568    #[test]
569    fn a_read_in_a_runtime_expression_renders_no_dependency_marker() {
570        let (html, _) = render_reading(|signal| {
571            let surrogate = crate::SignalSurrogate::new(signal.clone());
572            let _ = surrogate.get();
573            let _ = surrogate.read();
574        });
575        assert!(!html.contains("::topcoat::dep("), "{html}");
576    }
577
578    #[test]
579    fn a_tracked_read_outside_a_body_panics() {
580        let signal = Signal::new(SignalId(1), String::from("x"));
581        let panic = catch_unwind(AssertUnwindSafe(|| signal.get())).unwrap_err();
582        let message = panic.downcast::<&str>().expect("panics with a message");
583        assert!(message.contains("no view is collecting hoisted parts"));
584    }
585
586    #[test]
587    fn untracked_reads_work_outside_a_body() {
588        let signal = Signal::new(SignalId(1), String::from("x"));
589        assert_eq!(signal.get_untracked(), "x");
590        assert_eq!(signal.read_untracked(), "x");
591    }
592}