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