Skip to main content

topcoat_view/buffer/
handle.rs

1use std::sync::Arc;
2
3#[cfg(feature = "http")]
4use http::{HeaderMap, StatusCode};
5use topcoat_core::context::Cx;
6
7use crate::{
8    Formatter,
9    buffer::{InstructionPtr, Renderer, ViewBuffer, ViewBufferId, ViewBufferScope},
10};
11
12/// A handle to an HTML fragment.
13///
14/// A view handle may contain multiple sibling nodes, but opened tags must be closed
15/// so the fragment can be nested safely inside a larger document.
16///
17/// ```html
18/// <!-- Valid: all tags are closed, safe to nest -->
19/// <div>Hello</div>
20/// <p>World</p>
21///
22/// <!-- Invalid: unclosed tag would corrupt the parent document -->
23/// <div>Hello
24/// ```
25///
26/// The outermost [`View`](crate::View) returns a self-contained handle.
27/// It can be stored, sent to another task, or inserted into another view.
28/// Nested views return handles tied to the enclosing build. Those handles
29/// can only be rendered or inserted while that build is active.
30#[derive(Debug, Default, Clone)]
31pub struct ViewHandle {
32    repr: ViewRepr,
33}
34
35/// The kinds of view: a static string independent of any buffer, a handle
36/// into a buffer someone else holds, or an owned view carrying its own
37/// buffer.
38#[derive(Debug, Clone)]
39pub(super) enum ViewRepr {
40    /// Trusted static markup rendered verbatim, independent of any buffer.
41    Static(&'static str),
42    /// An instruction block in the buffer identified by `buffer`, starting
43    /// at `entry`.
44    Scoped {
45        buffer: ViewBufferId,
46        entry: InstructionPtr,
47        /// An estimate of the number of bytes the block writes when
48        /// rendered, accumulated while the view was built.
49        size_hint: usize,
50    },
51    /// An instruction block starting at `entry` in a buffer the view holds
52    /// on to itself.
53    Owned {
54        buffer: Arc<ViewBuffer>,
55        entry: InstructionPtr,
56        /// An estimate of the number of bytes the block writes when
57        /// rendered, accumulated while the view was built.
58        size_hint: usize,
59    },
60}
61
62impl Default for ViewRepr {
63    #[inline]
64    fn default() -> Self {
65        Self::Static("")
66    }
67}
68
69impl ViewHandle {
70    /// Creates the handle for an instruction block built in the buffer
71    /// identified by `buffer`, estimated to write `size_hint` bytes.
72    #[inline]
73    pub(super) fn from_scope(
74        buffer: ViewBufferId,
75        entry: InstructionPtr,
76        size_hint: usize,
77    ) -> Self {
78        Self {
79            repr: ViewRepr::Scoped {
80                buffer,
81                entry,
82                size_hint,
83            },
84        }
85    }
86
87    /// Unwraps the view into its representation.
88    #[inline]
89    pub(super) fn repr(self) -> ViewRepr {
90        self.repr
91    }
92
93    /// Returns an estimate of the number of bytes the view writes when
94    /// rendered.
95    #[inline]
96    #[must_use]
97    pub fn size_hint(&self) -> usize {
98        match &self.repr {
99            ViewRepr::Static(body) => body.len(),
100            ViewRepr::Scoped { size_hint, .. } | ViewRepr::Owned { size_hint, .. } => *size_hint,
101        }
102    }
103
104    /// Makes a nested handle self-contained by taking ownership of the
105    /// buffer its instructions were appended to.
106    ///
107    /// A handle that is self-contained already passes through, and the
108    /// buffer is dropped.
109    ///
110    /// # Panics
111    ///
112    /// Panics if the handle's instructions live in a different buffer.
113    #[must_use]
114    pub(crate) fn seal(self, buffer: ViewBuffer) -> Self {
115        match self.repr {
116            ViewRepr::Static(_) | ViewRepr::Owned { .. } => self,
117            ViewRepr::Scoped {
118                buffer: id,
119                entry,
120                size_hint,
121            } => {
122                assert!(
123                    id == buffer.id(),
124                    "tried to seal a view into a buffer it was not built in",
125                );
126                Self {
127                    repr: ViewRepr::Owned {
128                        buffer: Arc::new(buffer),
129                        entry,
130                        size_hint,
131                    },
132                }
133            }
134        }
135    }
136
137    /// Returns a `ViewHandle` that renders to an empty string.
138    #[inline]
139    #[must_use]
140    pub fn empty() -> Self {
141        Self::default()
142    }
143
144    /// Returns `true` if the view is statically known to render no output.
145    ///
146    /// A view holding an instruction block reports `false` even when the
147    /// block happens to write nothing.
148    #[inline]
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        matches!(self.repr, ViewRepr::Static(""))
152    }
153
154    /// Creates a view from a `&'static str` without escaping it and without checking for syntax
155    /// errors.
156    #[inline]
157    #[must_use]
158    pub const fn unescaped_unchecked(body: &'static str) -> Self {
159        Self {
160            repr: ViewRepr::Static(body),
161        }
162    }
163
164    /// Renders the view into an HTML string.
165    #[cfg_attr(
166        feature = "http",
167        doc = "",
168        doc = "Status codes and headers declared in the view are discarded;",
169        doc = "[`render_response`](Self::render_response) collects them."
170    )]
171    ///
172    /// # Panics
173    ///
174    /// Panics if the view is a nested handle rendered outside the build it
175    /// belongs to, or if a dynamic attribute key or element name in the
176    /// view contains a character that could break out of the identifier.
177    #[must_use]
178    #[track_caller]
179    pub fn render(self, cx: &Cx) -> String {
180        let mut html = String::with_capacity(self.size_hint());
181        self.render_into(cx, &mut Formatter::new(&mut html));
182        html
183    }
184
185    /// Renders the view into HTML together with the status code and response
186    /// headers declared in it.
187    ///
188    /// A view declares response metadata by placing an
189    /// [`http::StatusCode`](StatusCode), an [`http::HeaderMap`](HeaderMap),
190    /// or a single `(HeaderName, HeaderValue)` pair in the node position of
191    /// the `view!` macro. Competing declarations resolve by render order:
192    /// the first status code rendered wins, and the first part that mentions
193    /// a header name provides all of that name's values.
194    ///
195    /// # Panics
196    ///
197    /// Panics if the view is a nested handle rendered outside the build it
198    /// belongs to, or if a dynamic attribute key or element name in the
199    /// view contains a character that could break out of the identifier.
200    #[cfg(feature = "http")]
201    #[must_use]
202    #[track_caller]
203    pub fn render_response(self, cx: &Cx) -> RenderedResponse {
204        let mut html = String::with_capacity(self.size_hint());
205        let mut f = Formatter::new(&mut html);
206        self.render_into(cx, &mut f);
207        let (status_code, headers) = f.into_recorded();
208        RenderedResponse {
209            html,
210            status_code,
211            headers,
212        }
213    }
214
215    /// Writes the view's output through `f`.
216    ///
217    /// The formatter appends to its destination as it goes, so reserve the
218    /// [`size_hint`](Self::size_hint) up front to avoid reallocations. A
219    /// nested handle renders against the buffer of the build it belongs to,
220    /// which must be the one running on the current task.
221    ///
222    /// # Panics
223    ///
224    /// Panics if the view is a nested handle and no build is running on the
225    /// current task, or a different one is, or if a dynamic attribute key or
226    /// element name in the view contains a character that could break out of
227    /// the identifier.
228    #[track_caller]
229    pub fn render_into(self, cx: &Cx, f: &mut Formatter<'_>) {
230        match self.repr {
231            ViewRepr::Static(body) => f.write_str(body),
232            ViewRepr::Scoped { buffer, entry, .. } => ViewBufferScope::with(|active| {
233                assert!(
234                    active.id() == buffer,
235                    "tried to render a nested view handle outside the build it was built in",
236                );
237                Renderer::new(active, entry).execute(cx, f);
238            }),
239            ViewRepr::Owned { buffer, entry, .. } => {
240                Renderer::new(&buffer, entry).execute(cx, f);
241            }
242        }
243    }
244}
245
246/// The output of rendering a [`ViewHandle`] for an HTTP response.
247///
248/// Returned by [`ViewHandle::render_response`]: the rendered HTML alongside the
249/// status code and headers the view declared.
250#[cfg(feature = "http")]
251#[derive(Debug)]
252#[non_exhaustive]
253pub struct RenderedResponse {
254    /// The rendered HTML.
255    pub html: String,
256    /// The first status code the render encountered, if any.
257    pub status_code: Option<StatusCode>,
258    /// The collected response headers.
259    ///
260    /// Each name carries the values of the first render part that mentioned
261    /// it.
262    pub headers: HeaderMap,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::PartsWriter;
269
270    /// Appends a nested view to `buffer` in one synchronous burst from the
271    /// parts `f` pushes.
272    fn nested(buffer: &mut ViewBuffer, f: impl FnOnce(&mut PartsWriter<'_>)) -> ViewHandle {
273        buffer.block(f)
274    }
275
276    /// Builds a self-contained view in one synchronous burst from the parts
277    /// `f` pushes.
278    fn owned(f: impl FnOnce(&mut PartsWriter<'_>)) -> ViewHandle {
279        ViewBuffer::build(f)
280    }
281
282    /// Runs `f` with a build active on the current thread and returns its
283    /// output alongside the build's buffer.
284    fn in_scope<R>(f: impl FnOnce() -> R) -> (R, ViewBuffer) {
285        let mut slot = Some(Box::new(ViewBuffer::new()));
286        let output = {
287            let _buffer = ViewBufferScope::new(&mut slot);
288            f()
289        };
290        (output, *slot.expect("the buffer was swapped back on exit"))
291    }
292
293    #[test]
294    fn static_views_render_without_a_buffer() {
295        assert_eq!(ViewHandle::empty().render(&Cx::default()), "");
296        let view = ViewHandle::unescaped_unchecked("<b>raw</b>");
297        assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
298    }
299
300    #[test]
301    fn push_view_splices_nested_views() {
302        let mut buffer = ViewBuffer::new();
303        let inner = nested(&mut buffer, |parts| {
304            parts.push_str("a < b");
305        });
306        let outer = nested(&mut buffer, |parts| {
307            parts.push_str_unescaped("<p>");
308            parts.push_view_handle(inner);
309            parts.push_str_unescaped("</p>");
310        });
311        assert_eq!(outer.seal(buffer).render(&Cx::default()), "<p>a &lt; b</p>");
312    }
313
314    #[test]
315    fn document_order_follows_splice_order_not_buffer_order() {
316        let mut buffer = ViewBuffer::new();
317        // Built in reverse: `second` occupies earlier buffer addresses.
318        let second = nested(&mut buffer, |parts| {
319            parts.push_str("B");
320        });
321        let first = nested(&mut buffer, |parts| {
322            parts.push_str("A");
323        });
324        let outer = nested(&mut buffer, |parts| {
325            parts.push_view_handle(first);
326            parts.push_view_handle(second);
327        });
328        assert_eq!(outer.seal(buffer).render(&Cx::default()), "AB");
329    }
330
331    #[test]
332    fn sealed_views_own_their_buffer() {
333        let view = owned(|parts| {
334            parts.push_str("a < b");
335        });
336        assert!(matches!(view.repr, ViewRepr::Owned { .. }));
337        assert_eq!(view.render(&Cx::default()), "a &lt; b");
338    }
339
340    #[test]
341    fn owned_views_splice_across_buffers() {
342        let inner = owned(|parts| {
343            parts.push_str("a < b");
344        });
345        let outer = owned(|parts| {
346            parts.push_str_unescaped("<p>");
347            parts.push_view_handle(inner);
348            parts.push_str_unescaped("</p>");
349        });
350        assert_eq!(outer.render(&Cx::default()), "<p>a &lt; b</p>");
351    }
352
353    #[test]
354    fn owned_views_are_send_and_sync() {
355        fn assert_send_sync<T: Send + Sync>(_value: &T) {}
356
357        let view = owned(|parts| {
358            parts.push_str("x");
359        });
360        assert_send_sync(&view);
361    }
362
363    #[test]
364    fn static_views_are_spliced_verbatim() {
365        let outer = owned(|parts| {
366            parts.push_view_handle(ViewHandle::unescaped_unchecked("<hr>"));
367            parts.push_view_handle(ViewHandle::empty());
368        });
369        assert_eq!(outer.render(&Cx::default()), "<hr>");
370    }
371
372    #[test]
373    fn size_hint_accumulates_across_splices() {
374        let mut buffer = ViewBuffer::new();
375        let inner = nested(&mut buffer, |parts| {
376            parts.push_str_unescaped("12345678");
377        });
378        let outer = nested(&mut buffer, |parts| {
379            parts.push_view_handle(inner.clone());
380            parts.push_view_handle(inner);
381            parts.push_view_handle(ViewHandle::unescaped_unchecked("<hr>"));
382        });
383        let ViewRepr::Scoped { size_hint, .. } = outer.repr() else {
384            panic!("expected a nested view");
385        };
386        assert_eq!(size_hint, 8 + 8 + 4);
387    }
388
389    #[test]
390    fn a_nested_view_renders_inside_its_build() {
391        let (rendered, _buffer) = in_scope(|| {
392            let view = ViewBufferScope::with(|buffer| {
393                buffer.block(|parts| {
394                    parts.push_str("a < b");
395                })
396            });
397            view.render(&Cx::default())
398        });
399        assert_eq!(rendered, "a &lt; b");
400    }
401
402    #[test]
403    #[should_panic(expected = "outside of a `ViewBufferScope`")]
404    fn rendering_a_nested_view_outside_any_build_panics() {
405        let mut buffer = ViewBuffer::new();
406        let view = nested(&mut buffer, |_parts| {});
407        let _ = view.render(&Cx::default());
408    }
409
410    #[test]
411    #[should_panic(expected = "outside the build it was built in")]
412    fn rendering_a_nested_view_inside_a_different_build_panics() {
413        let mut buffer = ViewBuffer::new();
414        let view = nested(&mut buffer, |_parts| {});
415        in_scope(|| view.render(&Cx::default()));
416    }
417
418    #[test]
419    #[should_panic(expected = "outside the `view!` invocation it was built in")]
420    fn splicing_a_nested_view_from_a_different_buffer_panics() {
421        let mut built_in = ViewBuffer::new();
422        let view = nested(&mut built_in, |_parts| {});
423        let mut other = ViewBuffer::new();
424        nested(&mut other, |parts| {
425            parts.push_view_handle(view);
426        });
427    }
428
429    #[test]
430    #[should_panic(expected = "tried to seal a view into a buffer it was not built in")]
431    fn sealing_a_view_into_a_different_buffer_panics() {
432        let mut built_in = ViewBuffer::new();
433        let view = nested(&mut built_in, |_parts| {});
434        let _ = view.seal(ViewBuffer::new());
435    }
436
437    #[cfg(feature = "http")]
438    mod response {
439        use http::{
440            HeaderMap, HeaderName, HeaderValue, StatusCode,
441            header::{CACHE_CONTROL, SET_COOKIE},
442        };
443
444        use super::*;
445        use crate::NodeViewParts;
446
447        fn push_node(cx: &Cx, parts: &mut PartsWriter<'_>, value: impl NodeViewParts) {
448            value.into_view_parts(cx, parts);
449        }
450
451        #[test]
452        fn status_code_is_recorded_and_renders_nothing() {
453            let cx = &Cx::default();
454            let view = owned(|parts| {
455                push_node(cx, parts, "a");
456                push_node(cx, parts, StatusCode::NOT_FOUND);
457                push_node(cx, parts, "b");
458            });
459
460            let rendered = view.render_response(cx);
461            assert_eq!(rendered.html, "ab");
462            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
463            assert!(rendered.headers.is_empty());
464        }
465
466        #[test]
467        fn render_response_without_declarations_is_empty() {
468            let cx = &Cx::default();
469            let view = owned(|parts| {
470                push_node(cx, parts, "a");
471            });
472
473            let rendered = view.render_response(cx);
474            assert_eq!(rendered.html, "a");
475            assert_eq!(rendered.status_code, None);
476            assert!(rendered.headers.is_empty());
477        }
478
479        #[test]
480        fn render_discards_declarations() {
481            let cx = &Cx::default();
482            let view = owned(|parts| {
483                push_node(cx, parts, StatusCode::NOT_FOUND);
484                push_node(
485                    cx,
486                    parts,
487                    (CACHE_CONTROL, HeaderValue::from_static("no-store")),
488                );
489                push_node(cx, parts, "a");
490            });
491
492            assert_eq!(view.render(cx), "a");
493        }
494
495        #[test]
496        fn first_status_code_wins() {
497            let cx = &Cx::default();
498            let view = owned(|parts| {
499                push_node(cx, parts, StatusCode::NOT_FOUND);
500                push_node(cx, parts, StatusCode::OK);
501            });
502
503            let rendered = view.render_response(cx);
504            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
505        }
506
507        #[test]
508        fn first_mention_of_a_header_name_wins() {
509            let cx = &Cx::default();
510            let view = owned(|parts| {
511                push_node(
512                    cx,
513                    parts,
514                    (CACHE_CONTROL, HeaderValue::from_static("no-store")),
515                );
516                let mut later = HeaderMap::new();
517                later.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
518                later.insert(
519                    HeaderName::from_static("x-extra"),
520                    HeaderValue::from_static("1"),
521                );
522                push_node(cx, parts, later);
523            });
524
525            let rendered = view.render_response(cx);
526            assert_eq!(rendered.headers[CACHE_CONTROL], "no-store");
527            assert_eq!(rendered.headers["x-extra"], "1");
528        }
529
530        #[test]
531        fn one_map_keeps_all_values_for_a_name() {
532            let cx = &Cx::default();
533            let mut first = HeaderMap::new();
534            first.append(SET_COOKIE, HeaderValue::from_static("a=1"));
535            first.append(SET_COOKIE, HeaderValue::from_static("b=2"));
536            let mut later = HeaderMap::new();
537            later.insert(SET_COOKIE, HeaderValue::from_static("c=3"));
538
539            let view = owned(|parts| {
540                push_node(cx, parts, first);
541                push_node(cx, parts, later);
542            });
543
544            let rendered = view.render_response(cx);
545            let cookies: Vec<_> = rendered.headers.get_all(SET_COOKIE).iter().collect();
546            assert_eq!(cookies, ["a=1", "b=2"]);
547        }
548
549        #[test]
550        fn placement_decides_precedence_across_nested_views() {
551            let cx = &Cx::default();
552            let inner = owned(|parts| {
553                push_node(cx, parts, StatusCode::NOT_FOUND);
554                push_node(cx, parts, "inner");
555            });
556
557            // A status code before the nested view overrides it.
558            let outer = owned(|parts| {
559                push_node(cx, parts, StatusCode::FORBIDDEN);
560                parts.push_view_handle(inner.clone());
561            });
562            let rendered = outer.render_response(cx);
563            assert_eq!(rendered.status_code, Some(StatusCode::FORBIDDEN));
564
565            // A status code after the nested view is only a fallback.
566            let outer = owned(|parts| {
567                parts.push_view_handle(inner);
568                push_node(cx, parts, StatusCode::FORBIDDEN);
569            });
570            let rendered = outer.render_response(cx);
571            assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
572        }
573    }
574}