sycamore_web/node/
ssr_node.rs

1use std::any::{Any, TypeId};
2use std::collections::HashSet;
3use std::sync::{Arc, Mutex};
4
5use once_cell::sync::Lazy;
6
7use crate::*;
8
9pub enum SsrNode {
10    Element {
11        tag: Cow<'static, str>,
12        attributes: Vec<(Cow<'static, str>, Cow<'static, str>)>,
13        bool_attributes: Vec<(Cow<'static, str>, bool)>,
14        children: Vec<Self>,
15        // NOTE: This field is boxed to avoid allocating memory for a field that is rarely used.
16        inner_html: Option<Box<Cow<'static, str>>>,
17        hk_key: Option<HydrationKey>,
18    },
19    TextDynamic {
20        text: Arc<Mutex<String>>,
21    },
22    TextStatic {
23        text: Cow<'static, str>,
24    },
25    Marker,
26    /// SSR by default does not update to any dynamic changes in the view. This special node allows
27    /// dynamically changing the view tree before it is rendered.
28    ///
29    /// This is used for updating the view with suspense content once it is resolved.
30    Dynamic {
31        view: Arc<Mutex<View<Self>>>,
32    },
33}
34
35impl From<SsrNode> for View<SsrNode> {
36    fn from(node: SsrNode) -> Self {
37        View::from_node(node)
38    }
39}
40
41impl ViewNode for SsrNode {
42    fn append_child(&mut self, child: Self) {
43        match self {
44            Self::Element { children, .. } => {
45                children.push(child);
46            }
47            _ => panic!("can only append child to an element"),
48        }
49    }
50
51    fn create_dynamic_view<U: Into<View<Self>> + 'static>(
52        mut f: impl FnMut() -> U + 'static,
53    ) -> View<Self> {
54        // If `view` is just a single text node, we can just return this node since text nodes are
55        // specialized. Otherwise, we must create two marker nodes to represent start and end
56        // respectively.
57        if TypeId::of::<U>() == TypeId::of::<String>() {
58            // TODO: Once the reactive graph is sync, we can replace this with a signal.
59            let text = Arc::new(Mutex::new(String::new()));
60            create_effect({
61                let text = text.clone();
62                move || {
63                    let mut value = Some(f());
64                    let value: &mut Option<String> =
65                        (&mut value as &mut dyn Any).downcast_mut().unwrap();
66                    *text.lock().unwrap() = value.take().unwrap();
67                }
68            });
69            View::from(SsrNode::TextDynamic { text })
70        } else {
71            let start = Self::create_marker_node();
72            let end = Self::create_marker_node();
73            // TODO: Once the reactive graph is sync, we can replace this with a signal.
74            let view = Arc::new(Mutex::new(View::new()));
75            create_effect({
76                let view = view.clone();
77                move || {
78                    let value = f();
79                    *view.lock().unwrap() = value.into();
80                }
81            });
82            View::from((start, Self::Dynamic { view }, end))
83        }
84    }
85}
86
87impl ViewHtmlNode for SsrNode {
88    fn create_element(tag: Cow<'static, str>) -> Self {
89        let hk_key = if IS_HYDRATING.get() {
90            let reg: HydrationRegistry = use_context();
91            Some(reg.next_key())
92        } else {
93            None
94        };
95        Self::Element {
96            tag,
97            attributes: Vec::new(),
98            bool_attributes: Vec::new(),
99            children: Vec::new(),
100            inner_html: None,
101            hk_key,
102        }
103    }
104
105    fn create_element_ns(_namespace: &str, tag: Cow<'static, str>) -> Self {
106        // SVG namespace is ignored in SSR mode.
107        Self::create_element(tag)
108    }
109
110    fn create_text_node(text: Cow<'static, str>) -> Self {
111        Self::TextStatic { text }
112    }
113
114    fn create_dynamic_text_node(text: Cow<'static, str>) -> Self {
115        Self::TextDynamic {
116            text: Arc::new(Mutex::new(text.to_string())),
117        }
118    }
119
120    fn create_marker_node() -> Self {
121        Self::Marker
122    }
123
124    fn set_attribute(&mut self, name: Cow<'static, str>, value: StringAttribute) {
125        match self {
126            Self::Element { attributes, .. } => {
127                if let Some(value) = value.evaluate() {
128                    attributes.push((name, value))
129                }
130            }
131            _ => panic!("can only set attribute on an element"),
132        }
133    }
134
135    fn set_bool_attribute(&mut self, name: Cow<'static, str>, value: BoolAttribute) {
136        match self {
137            Self::Element {
138                bool_attributes, ..
139            } => bool_attributes.push((name, value.evaluate())),
140            _ => panic!("can only set attribute on an element"),
141        }
142    }
143
144    fn set_property(&mut self, _name: Cow<'static, str>, _value: MaybeDyn<JsValue>) {
145        // Noop in SSR mode.
146    }
147
148    fn set_event_handler(
149        &mut self,
150        _name: Cow<'static, str>,
151        _handler: impl FnMut(web_sys::Event) + 'static,
152    ) {
153        // Noop in SSR mode.
154    }
155
156    fn set_inner_html(&mut self, inner_html: Cow<'static, str>) {
157        match self {
158            Self::Element {
159                inner_html: slot, ..
160            } => *slot = Some(Box::new(inner_html)),
161            _ => panic!("can only set inner_html on an element"),
162        }
163    }
164
165    fn as_web_sys(&self) -> &web_sys::Node {
166        panic!("`as_web_sys()` is not supported in SSR mode")
167    }
168
169    fn from_web_sys(_node: web_sys::Node) -> Self {
170        panic!("`from_web_sys()` is not supported in SSR mode")
171    }
172}
173
174/// A list of all the void HTML elements. We need this to know how to render them to a string.
175static VOID_ELEMENTS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
176    [
177        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
178        "source", "track", "wbr", "command", "keygen", "menuitem",
179    ]
180    .into_iter()
181    .collect()
182});
183
184/// Recursively render `node` by appending to `buf`.
185pub(crate) fn render_recursive(node: &SsrNode, buf: &mut String) {
186    match node {
187        SsrNode::Element {
188            tag,
189            attributes,
190            bool_attributes,
191            children,
192            inner_html,
193            hk_key,
194        } => {
195            buf.push('<');
196            buf.push_str(tag);
197            for (name, value) in attributes {
198                buf.push(' ');
199                buf.push_str(name);
200                buf.push_str("=\"");
201                html_escape::encode_double_quoted_attribute_to_string(value, buf);
202                buf.push('"');
203            }
204            for (name, value) in bool_attributes {
205                if *value {
206                    buf.push(' ');
207                    buf.push_str(name);
208                }
209            }
210
211            if let Some(hk_key) = hk_key {
212                buf.push_str(" data-hk=\"");
213                buf.push_str(&hk_key.to_string());
214                buf.push('"');
215            }
216            buf.push('>');
217
218            let is_void = VOID_ELEMENTS.contains(tag.as_ref());
219
220            if is_void {
221                assert!(
222                    children.is_empty() && inner_html.is_none(),
223                    "void elements cannot have children or inner_html"
224                );
225                return;
226            }
227            if let Some(inner_html) = inner_html {
228                assert!(
229                    children.is_empty(),
230                    "inner_html and children are mutually exclusive"
231                );
232                buf.push_str(inner_html);
233            } else {
234                for child in children {
235                    render_recursive(child, buf);
236                }
237            }
238
239            if !is_void {
240                buf.push_str("</");
241                buf.push_str(tag);
242                buf.push('>');
243            }
244        }
245        SsrNode::TextDynamic { text } => {
246            buf.push_str("<!--t-->"); // For dynamic text, add a marker for hydrating it.
247            html_escape::encode_text_to_string(text.lock().unwrap().as_str(), buf);
248            buf.push_str("<!-->"); // End of dynamic text.
249        }
250        SsrNode::TextStatic { text } => {
251            html_escape::encode_text_to_string(text, buf);
252        }
253        SsrNode::Marker => {
254            buf.push_str("<!--/-->");
255        }
256        SsrNode::Dynamic { view } => {
257            render_recursive_view(&view.lock().unwrap(), buf);
258        }
259    }
260}
261
262/// Recursively render a [`View`] to a string by calling `render_recursive` on each node.
263pub(crate) fn render_recursive_view(view: &View, buf: &mut String) {
264    for node in &view.nodes {
265        render_recursive(node, buf);
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use expect_test::{expect, Expect};
272
273    use super::*;
274    use crate::tags::*;
275
276    fn check<T: Into<View>>(view: impl FnOnce() -> T, expect: Expect) {
277        let actual = render_to_string(move || view().into());
278        expect.assert_eq(&actual);
279    }
280
281    #[test]
282    fn hello_world() {
283        check(move || "Hello, world!", expect!["Hello, world!"]);
284    }
285
286    #[test]
287    fn render_escaped_text() {
288        check(
289            move || "<script>alert('xss')</script>",
290            expect!["&lt;script&gt;alert('xss')&lt;/script&gt;"],
291        );
292    }
293
294    #[test]
295    fn render_inner_html() {
296        check(
297            move || div().dangerously_set_inner_html("<p>hello</p>"),
298            expect![[r#"<div data-hk="0.0"><p>hello</p></div>"#]],
299        );
300    }
301
302    #[test]
303    fn render_void_element() {
304        check(br, expect![[r#"<br data-hk="0.0">"#]]);
305        check(
306            move || input().value("value"),
307            expect![[r#"<input value="value" data-hk="0.0">"#]],
308        );
309    }
310
311    #[test]
312    fn fragments() {
313        check(
314            move || (p().children("1"), p().children("2"), p().children("3")),
315            expect![[r#"<p data-hk="0.0">1</p><p data-hk="0.1">2</p><p data-hk="0.2">3</p>"#]],
316        );
317    }
318
319    #[test]
320    fn indexed() {
321        check(
322            move || {
323                sycamore_macro::view! {
324                    ul {
325                        Indexed(
326                            list=vec![1, 2],
327                            view=|i| sycamore_macro::view! { li { (i) } },
328                        )
329                    }
330                }
331            },
332            expect![[r#"<ul data-hk="0.0"><li data-hk="0.1">1</li><li data-hk="0.2">2</li></ul>"#]],
333        );
334    }
335
336    #[test]
337    fn bind() {
338        // bind always attaches to a JS prop so it is not rendered in SSR.
339        check(
340            move || {
341                let value = create_signal(String::new());
342                sycamore_macro::view! {
343                    input(bind:value=value)
344                }
345            },
346            expect![[r#"<input data-hk="0.0">"#]],
347        );
348    }
349
350    #[test]
351    fn svg_element() {
352        check(
353            move || {
354                sycamore_macro::view! {
355                    svg(xmlns="http://www.w2.org/2000/svg") {
356                        rect()
357                    }
358                }
359            },
360            expect![[
361                r#"<svg xmlns="http://www.w2.org/2000/svg" data-hk="0.0"><rect data-hk="0.1"></rect></svg>"#
362            ]],
363        );
364        check(
365            move || {
366                sycamore_macro::view! {
367                    svg_a()
368                }
369            },
370            expect![[r#"<a data-hk="0.0"></a>"#]],
371        );
372    }
373
374    #[test]
375    fn dynamic_text() {
376        check(
377            move || {
378                let value = create_signal(0);
379                let view = sycamore_macro::view! {
380                    p { (value) }
381                };
382                value.set(1);
383                view
384            },
385            expect![[r#"<p data-hk="0.0"><!--/-->1<!--/--></p>"#]],
386        );
387    }
388}