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