Skip to main content

topcoat_runtime/
expr.rs

1use std::cell::Cell;
2
3use serde::Serialize;
4use topcoat_core::context::Cx;
5use topcoat_view::{NodeViewParts, PartsWriter};
6
7use crate::{Js, Surrogate, Surrogated};
8
9/// A value with a JavaScript expression that can produce it in the browser.
10///
11/// Expressions that read no signals render as static content. Their
12/// JavaScript remains available when needed for browser execution.
13#[derive(Debug, Clone)]
14pub struct Expr<T> {
15    pub(crate) evaluated: T,
16    pub(crate) js: Js,
17    pub(crate) is_static: bool,
18}
19
20impl<T> Expr<T> {
21    /// Evaluates an expression while observing signal reads.
22    ///
23    /// The evaluation is synchronous. Its signal reads must represent the
24    /// dependencies of the JavaScript expression as well.
25    #[inline]
26    pub fn evaluate(evaluate: impl FnOnce() -> T, js: Js) -> Self {
27        let _scope = ReadScope::enter();
28        let evaluated = evaluate();
29        Self {
30            evaluated,
31            js,
32            is_static: SIGNAL_READ.get() == Some(false),
33        }
34    }
35
36    /// Whether this expression is known not to require reactive evaluation.
37    ///
38    /// Signal reads during [`evaluate`](Self::evaluate) make it dynamic.
39    #[inline]
40    #[must_use]
41    pub fn is_static(&self) -> bool {
42        self.is_static
43    }
44
45    /// Makes this expression dynamic even if it reads no signals.
46    ///
47    /// A static expression only renders its value. A dynamic one is also
48    /// evaluated in the browser whenever its content renders. As a bind
49    /// attribute, this sets the element's live state on every render, such as
50    /// the value of an input the user has edited, instead of only its default.
51    ///
52    /// ```rust
53    /// use topcoat::{Result, context::Cx, runtime::Expr, view::*};
54    ///
55    /// #[component]
56    /// async fn search(cx: &Cx) -> Result<impl View> {
57    ///     // Every render clears what the user typed.
58    ///     let query = Expr::from(String::new()).dynamic();
59    ///
60    ///     Ok(view! { <input :value=(query)> })
61    /// }
62    /// ```
63    #[inline]
64    #[must_use]
65    pub fn dynamic(mut self) -> Self {
66        self.is_static = false;
67        self
68    }
69
70    /// Returns the value and its JavaScript, including for static expressions.
71    #[inline]
72    pub fn into_evaluated_and_js(self) -> (T, Js) {
73        (self.evaluated, self.js)
74    }
75
76    /// Returns the captured value, carrying its dependencies into evaluation.
77    #[doc(hidden)]
78    pub fn into_captured_value(self) -> T::Surrogate
79    where
80        T: Surrogated,
81    {
82        if !self.is_static {
83            mark_signal_read();
84        }
85        self.evaluated.into_surrogate()
86    }
87}
88
89impl<T> From<T> for Expr<T>
90where
91    T: Surrogated,
92    T::Surrogate: Serialize,
93{
94    /// Creates a static expression from a value in the runtime vocabulary.
95    ///
96    /// # Panics
97    ///
98    /// Panics if the value's surrogate cannot be serialized.
99    fn from(value: T) -> Self {
100        let surrogate = value.into_surrogate();
101        let js = Js::builder().surrogate(&surrogate).build();
102        Self::evaluate(|| surrogate.into_real(), js)
103    }
104}
105
106impl<T> NodeViewParts for Expr<T>
107where
108    T: NodeViewParts,
109{
110    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
111        if self.is_static {
112            self.evaluated.into_view_parts(cx, parts);
113            return;
114        }
115
116        // <!-- ::topcoat::expr::start("<js>") -->
117        //
118        // The comment context seals the source, so a `"` inside it renders
119        // as `&quot;` and the quotes stay unambiguous delimiters on the
120        // client.
121        parts.push_comment(|comment| {
122            comment.push_promoted_str_unescaped(&"::topcoat::expr::start(\"");
123            self.js.write(comment);
124            comment.push_promoted_str_unescaped(&"\")");
125        });
126        self.evaluated.into_view_parts(cx, parts);
127        parts.push_comment(|comment| {
128            comment.push_promoted_str_unescaped(&"::topcoat::expr::end");
129        });
130    }
131}
132
133thread_local! {
134    static SIGNAL_READ: Cell<Option<bool>> = const { Cell::new(None) };
135}
136
137/// Records a read and returns whether an expression is being evaluated.
138pub(crate) fn mark_signal_read() -> bool {
139    if SIGNAL_READ.get().is_some() {
140        SIGNAL_READ.set(Some(true));
141        true
142    } else {
143        false
144    }
145}
146
147/// Restores the enclosing synchronous evaluation, including during unwinding.
148struct ReadScope {
149    previous: Option<bool>,
150}
151
152impl ReadScope {
153    fn enter() -> Self {
154        Self {
155            previous: SIGNAL_READ.replace(Some(false)),
156        }
157    }
158}
159
160impl Drop for ReadScope {
161    fn drop(&mut self) {
162        let read = SIGNAL_READ.replace(self.previous) == Some(true);
163        if read {
164            mark_signal_read();
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::{panic::catch_unwind, sync::Barrier};
172
173    use super::*;
174
175    #[test]
176    fn nested_evaluations_propagate_reads_without_contaminating_siblings() {
177        let outer = Expr::evaluate(
178            || {
179                let inner = Expr::evaluate(mark_signal_read, Js::source(""));
180                assert!(!inner.is_static());
181                let sibling = Expr::evaluate(|| 1, Js::source(""));
182                assert!(sibling.is_static());
183            },
184            Js::source(""),
185        );
186        assert!(!outer.is_static());
187        assert!(Expr::evaluate(|| 1, Js::source("")).is_static());
188    }
189
190    #[test]
191    fn panic_restores_the_parent_scope_and_preserves_its_reads() {
192        let outer = Expr::evaluate(
193            || {
194                let panic = catch_unwind(|| {
195                    Expr::evaluate(
196                        || {
197                            mark_signal_read();
198                            panic!("evaluation failed");
199                        },
200                        Js::source(""),
201                    )
202                });
203                assert!(panic.is_err());
204            },
205            Js::source(""),
206        );
207        assert!(!outer.is_static());
208        assert!(Expr::evaluate(|| 1, Js::source("")).is_static());
209    }
210
211    #[test]
212    fn concurrent_evaluations_do_not_share_reads() {
213        let barrier = Barrier::new(2);
214        std::thread::scope(|scope| {
215            let dynamic = scope.spawn(|| {
216                Expr::evaluate(
217                    || {
218                        mark_signal_read();
219                        barrier.wait();
220                        barrier.wait();
221                    },
222                    Js::source(""),
223                )
224            });
225            let constant = Expr::evaluate(
226                || {
227                    barrier.wait();
228                    barrier.wait();
229                },
230                Js::source(""),
231            );
232            assert!(constant.is_static());
233            assert!(!dynamic.join().unwrap().is_static());
234        });
235    }
236}