Skip to main content

topcoat_view/
hoist.rs

1//! Hoisted parts render before the enclosing view's next content.
2//!
3//! Call [`hoist`] while rendering to collect a part. The enclosing
4//! [`HoistView`] places collected parts before its initial content or
5//! next replacement, in the order they were collected.
6//!
7//! Each part belongs to the innermost active collection. Collections are
8//! active only during their view's polls, so interleaved views do not
9//! collect each other's parts.
10
11use std::{
12    cell::Cell,
13    collections::HashSet,
14    hash::Hash,
15    marker::PhantomData,
16    pin::Pin,
17    task::{Context, Poll},
18};
19
20use pin_project_lite::pin_project;
21use siphasher::sip128::{Hasher128, SipHasher13};
22use topcoat_core::error::Result;
23
24use crate::{PartsWriter, View, ViewBuffer, ViewBufferScope, ViewFirst, ViewHandle, ViewSwap};
25
26/// A hoisted part, pushed through the writer of the content it lands in.
27type HoistedPart = Box<dyn FnOnce(&mut PartsWriter<'_>) + Send>;
28
29/// The key of a part hoisted at most once per content.
30///
31/// Include a type or tag in the key to distinguish different kinds of part
32/// that happen to share an id.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct HoistKey(u128);
35
36impl HoistKey {
37    /// Creates the key of the part identified by `key`.
38    pub fn new(key: impl Hash) -> Self {
39        let mut hasher = SipHasher13::new();
40        key.hash(&mut hasher);
41        Self(hasher.finish128().as_u128())
42    }
43}
44
45/// The parts hoisted into one piece of content, with the keys of those
46/// hoisted at most once.
47#[derive(Default)]
48struct Hoisted {
49    parts: Vec<HoistedPart>,
50    keys: HashSet<HoistKey>,
51}
52
53impl Hoisted {
54    /// Takes the parts collected so far, leaving the collection empty.
55    fn take(&mut self) -> Vec<HoistedPart> {
56        self.keys.clear();
57        std::mem::take(&mut self.parts)
58    }
59}
60
61thread_local! {
62    /// The parts hoisted so far into the view polling on the current
63    /// thread, if one is collecting.
64    static CURRENT: Cell<Option<Hoisted>> = const { Cell::new(None) };
65}
66
67/// Hoists a part into the content of the enclosing view.
68///
69/// `build` receives a writer for node content. Use its methods to select
70/// the appropriate HTML context. The part renders before the enclosing
71/// [`HoistView`]'s next content, in the order parts were hoisted.
72///
73/// # Panics
74///
75/// Panics if no view is collecting hoisted parts. Spawned tasks do not
76/// inherit the collection.
77#[track_caller]
78pub fn hoist(build: impl FnOnce(&mut PartsWriter<'_>) + Send + 'static) {
79    with_collecting(|hoisted| hoisted.parts.push(Box::new(build)));
80}
81
82/// Hoists a part into the content of the enclosing view unless a part with
83/// the same key was already hoisted into that content.
84///
85/// Use this when repeated parts have the same meaning. Each replacement
86/// starts a fresh collection, so the same key may appear in a later update.
87/// `build` runs only for a part that is kept. Otherwise, [`hoist`]'s rules apply.
88///
89/// # Panics
90///
91/// Panics if no view is collecting hoisted parts, like [`hoist`].
92#[track_caller]
93pub fn hoist_once(key: HoistKey, build: impl FnOnce(&mut PartsWriter<'_>) + Send + 'static) {
94    with_collecting(|hoisted| {
95        if hoisted.keys.insert(key) {
96            hoisted.parts.push(Box::new(build));
97        }
98    });
99}
100
101/// Runs `f` on the collection of the view polling on the current thread.
102#[track_caller]
103fn with_collecting(f: impl FnOnce(&mut Hoisted)) {
104    let mut collected = CURRENT.take();
105    let Some(hoisted) = &mut collected else {
106        panic!(
107            "no view is collecting hoisted parts: `hoist` must be called while a page, layout, \
108             component, or shard body runs"
109        );
110    };
111    f(hoisted);
112    CURRENT.set(collected);
113}
114
115/// Makes `slot` the collection receiving hoisted parts for exactly the
116/// duration of a synchronous region.
117///
118/// Creating the guard moves the collection into the thread local, and
119/// dropping it moves the collection back into `slot`, also when the region
120/// panics. A collection installed before is restored at the same time.
121#[must_use = "the collection is uninstalled when the guard drops"]
122struct HoistGuard<'a> {
123    slot: &'a mut Hoisted,
124    prev: Option<Hoisted>,
125    /// The guard restores a thread local, so it must stay on the thread it
126    /// was created on.
127    _not_send: PhantomData<*const ()>,
128}
129
130impl<'a> HoistGuard<'a> {
131    fn install(slot: &'a mut Hoisted) -> Self {
132        let prev = CURRENT.replace(Some(std::mem::take(slot)));
133        Self {
134            slot,
135            prev,
136            _not_send: PhantomData,
137        }
138    }
139}
140
141impl Drop for HoistGuard<'_> {
142    fn drop(&mut self) {
143        *self.slot = CURRENT.replace(self.prev.take()).unwrap_or_default();
144    }
145}
146
147pin_project! {
148    /// Collects the parts hoisted while a view polls and prepends them to
149    /// the content it resolves.
150    ///
151    /// Calls to [`hoist`] during a poll add parts to this view's collection.
152    /// They render before the next initial content or replacement.
153    /// A nested `HoistView` collects its own parts while it is being polled.
154    pub struct HoistView<V> {
155        #[pin]
156        view: V,
157        hoisted: Hoisted,
158    }
159}
160
161impl<V: View> HoistView<V> {
162    /// Wraps `view` to collect the parts hoisted while it polls.
163    pub fn new(view: V) -> Self {
164        Self {
165            view,
166            hoisted: Hoisted::default(),
167        }
168    }
169}
170
171impl<V: View> View for HoistView<V> {
172    fn poll_first(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
173        let this = self.project();
174        let poll = {
175            let _guard = HoistGuard::install(this.hoisted);
176            this.view.poll_first(cx)
177        };
178        match poll {
179            Poll::Ready(Ok(ViewFirst { content, live })) => Poll::Ready(Ok(ViewFirst {
180                content: prepend(this.hoisted, content),
181                live,
182            })),
183            poll => poll,
184        }
185    }
186
187    fn poll_swap(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<Option<ViewSwap>>> {
188        let this = self.project();
189        let poll = {
190            let _guard = HoistGuard::install(this.hoisted);
191            this.view.poll_swap(cx)
192        };
193        match poll {
194            Poll::Ready(Ok(Some(ViewSwap {
195                region,
196                replacement,
197            }))) => Poll::Ready(Ok(Some(ViewSwap {
198                region,
199                replacement: prepend(this.hoisted, replacement),
200            }))),
201            poll => poll,
202        }
203    }
204}
205
206/// Splices the parts collected so far ahead of `content`, leaving the
207/// collection empty.
208///
209/// Content with nothing hoisted passes through untouched, so a view that
210/// never hoists costs no block in the buffer. Inside a running build the
211/// block joins that build's buffer; outside one, as when a swap resolves
212/// after the first content was sealed, the block is self-contained.
213fn prepend(hoisted: &mut Hoisted, content: ViewHandle) -> ViewHandle {
214    if hoisted.parts.is_empty() {
215        return content;
216    }
217    let parts = hoisted.take();
218    let build = |writer: &mut PartsWriter<'_>| {
219        for part in parts {
220            part(writer);
221        }
222        writer.push_view_handle(content);
223    };
224    if ViewBufferScope::is_active() {
225        ViewBufferScope::with(|buffer| buffer.block(build))
226    } else {
227        let mut buffer = ViewBuffer::new();
228        buffer.block(build).seal(buffer)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use std::{
235        panic::{AssertUnwindSafe, catch_unwind},
236        pin::pin,
237        task::Waker,
238    };
239
240    use topcoat_core::{
241        context::Cx,
242        identity::{Identity, SiteKey},
243    };
244
245    use super::*;
246    use crate::{RegionId, internal::ScopeView};
247
248    /// Hoists a comment carrying `text`.
249    fn hoist_comment(text: &'static str) {
250        hoist(move |writer| {
251            writer.push_comment(|comment| {
252                comment.push_static_str(text);
253            });
254        });
255    }
256
257    /// A view that hoists on every poll and resolves static content, then
258    /// one swap.
259    ///
260    /// The first poll of each method is pending, so a test can interleave
261    /// two of them across a yield point; the second resolves.
262    struct Probe {
263        name: &'static str,
264        polled_first: bool,
265        polled_swap: bool,
266    }
267
268    impl Probe {
269        fn new(name: &'static str) -> Self {
270            Self {
271                name,
272                polled_first: false,
273                polled_swap: false,
274            }
275        }
276    }
277
278    impl View for Probe {
279        fn poll_first(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
280            hoist_comment(self.name);
281            if std::mem::replace(&mut self.polled_first, true) {
282                Poll::Ready(Ok(ViewFirst {
283                    content: ViewBufferScope::with(|buffer| {
284                        buffer.block(|writer| {
285                            writer.push_static_str("content");
286                        })
287                    }),
288                    live: true,
289                }))
290            } else {
291                Poll::Pending
292            }
293        }
294
295        fn poll_swap(
296            mut self: Pin<&mut Self>,
297            _cx: &mut Context<'_>,
298        ) -> Poll<Result<Option<ViewSwap>>> {
299            if std::mem::replace(&mut self.polled_swap, true) {
300                return Poll::Ready(Ok(None));
301            }
302            hoist_comment("swap");
303            // A swap resolves after the first content was sealed, with no
304            // build running, so its replacement is self-contained.
305            Poll::Ready(Ok(Some(ViewSwap {
306                region: RegionId::new(Identity::ROOT, SiteKey::new(file!(), line!(), column!(), 0)),
307                replacement: ViewBuffer::build(|writer| {
308                    writer.push_static_str("replacement");
309                }),
310            })))
311        }
312    }
313
314    /// Drives `view` to its first content and renders it.
315    fn render_first(view: impl View) -> String {
316        let mut view = pin!(ScopeView::new(view));
317        let mut cx = Context::from_waker(Waker::noop());
318        loop {
319            if let Poll::Ready(first) = view.as_mut().poll_first(&mut cx) {
320                return first.unwrap().content.render(&Cx::default());
321            }
322        }
323    }
324
325    #[test]
326    fn hoisting_outside_a_collecting_view_panics() {
327        let panic = catch_unwind(|| hoist_comment("x")).unwrap_err();
328        let message = panic.downcast::<&str>().expect("panics with a message");
329        assert!(message.contains("no view is collecting hoisted parts"));
330    }
331
332    #[test]
333    fn hoisted_parts_render_ahead_of_the_first_content() {
334        let html = render_first(HoistView::new(Probe::new("a")));
335        // The probe hoists once per poll and resolves on its second poll.
336        assert_eq!(html, "<!--a--><!--a-->content");
337    }
338
339    #[test]
340    fn hoisted_parts_render_ahead_of_the_next_swap() {
341        let mut view = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
342            "a"
343        ))));
344        let mut cx = Context::from_waker(Waker::noop());
345        while view.as_mut().poll_first(&mut cx).is_pending() {}
346
347        let Poll::Ready(Ok(Some(swap))) = view.as_mut().poll_swap(&mut cx) else {
348            panic!("expected a swap");
349        };
350        assert_eq!(
351            swap.replacement.render(&Cx::default()),
352            "<!--swap-->replacement"
353        );
354        assert!(matches!(
355            view.as_mut().poll_swap(&mut cx),
356            Poll::Ready(Ok(None))
357        ));
358    }
359
360    #[test]
361    fn a_view_that_hoists_nothing_passes_its_content_through() {
362        struct Plain;
363
364        impl View for Plain {
365            fn poll_first(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
366                Poll::Ready(Ok(ViewFirst {
367                    content: ViewHandle::empty(),
368                    live: false,
369                }))
370            }
371
372            fn poll_swap(
373                self: Pin<&mut Self>,
374                _cx: &mut Context<'_>,
375            ) -> Poll<Result<Option<ViewSwap>>> {
376                Poll::Ready(Ok(None))
377            }
378        }
379
380        assert_eq!(render_first(HoistView::new(Plain)), "");
381    }
382
383    #[test]
384    fn interleaved_siblings_each_collect_their_own_parts() {
385        let mut first = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
386            "a"
387        ))));
388        let mut second = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
389            "b"
390        ))));
391        let mut cx = Context::from_waker(Waker::noop());
392
393        // Interleave the two views across their pending polls.
394        assert!(first.as_mut().poll_first(&mut cx).is_pending());
395        assert!(second.as_mut().poll_first(&mut cx).is_pending());
396        let Poll::Ready(Ok(first)) = first.as_mut().poll_first(&mut cx) else {
397            panic!("expected content");
398        };
399        let Poll::Ready(Ok(second)) = second.as_mut().poll_first(&mut cx) else {
400            panic!("expected content");
401        };
402
403        assert_eq!(
404            first.content.render(&Cx::default()),
405            "<!--a--><!--a-->content"
406        );
407        assert_eq!(
408            second.content.render(&Cx::default()),
409            "<!--b--><!--b-->content"
410        );
411    }
412
413    #[test]
414    fn a_nested_view_collects_its_own_parts_and_restores_the_outer_ones() {
415        /// Hoists into the outer collection around polling an inner view.
416        struct Outer<V> {
417            inner: Pin<Box<V>>,
418        }
419
420        impl<V: View> View for Outer<V> {
421            fn poll_first(
422                mut self: Pin<&mut Self>,
423                cx: &mut Context<'_>,
424            ) -> Poll<Result<ViewFirst>> {
425                hoist_comment("outer");
426                let inner = std::task::ready!(self.inner.as_mut().poll_first(cx))?;
427                hoist_comment("outer again");
428                Poll::Ready(Ok(ViewFirst {
429                    content: ViewBufferScope::with(|buffer| {
430                        buffer.block(|writer| {
431                            writer.push_static_str("[");
432                            writer.push_view_handle(inner.content);
433                            writer.push_static_str("]");
434                        })
435                    }),
436                    live: false,
437                }))
438            }
439
440            fn poll_swap(
441                self: Pin<&mut Self>,
442                _cx: &mut Context<'_>,
443            ) -> Poll<Result<Option<ViewSwap>>> {
444                Poll::Ready(Ok(None))
445            }
446        }
447
448        let html = render_first(HoistView::new(Outer {
449            inner: Box::pin(HoistView::new(Probe::new("inner"))),
450        }));
451        // The outer view hoists on both of its polls and the inner view on
452        // both of its own; each lands in its own content.
453        assert_eq!(
454            html,
455            "<!--outer--><!--outer--><!--outer again-->[<!--inner--><!--inner-->content]"
456        );
457    }
458
459    #[test]
460    fn the_collection_is_restored_when_a_poll_panics() {
461        struct Boom;
462
463        impl View for Boom {
464            fn poll_first(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
465                hoist_comment("lost");
466                panic!("boom")
467            }
468
469            fn poll_swap(
470                self: Pin<&mut Self>,
471                _cx: &mut Context<'_>,
472            ) -> Poll<Result<Option<ViewSwap>>> {
473                panic!("boom")
474            }
475        }
476
477        let mut view = pin!(HoistView::new(Boom));
478        let result = catch_unwind(AssertUnwindSafe(|| {
479            let mut cx = Context::from_waker(Waker::noop());
480            let _ = view.as_mut().poll_first(&mut cx);
481        }));
482        assert!(result.is_err());
483        // Nothing is collecting any more, so hoisting panics again rather
484        // than landing in the abandoned view's collection.
485        assert!(catch_unwind(|| hoist_comment("x")).is_err());
486        assert_eq!(view.hoisted.parts.len(), 1);
487    }
488
489    /// A view that hoists the comments `a` and `b` once each under their
490    /// own keys, twice over, on every poll, then resolves static content
491    /// and one swap.
492    struct OnceProbe {
493        resolved_first: bool,
494        resolved_swap: bool,
495    }
496
497    impl OnceProbe {
498        fn hoist_twice() {
499            for text in ["a", "b", "a", "b"] {
500                hoist_once(HoistKey::new(("comment", text)), move |writer| {
501                    writer.push_comment(|comment| {
502                        comment.push_static_str(text);
503                    });
504                });
505            }
506        }
507    }
508
509    impl View for OnceProbe {
510        fn poll_first(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
511            Self::hoist_twice();
512            if std::mem::replace(&mut self.resolved_first, true) {
513                Poll::Ready(Ok(ViewFirst {
514                    content: ViewBufferScope::with(|buffer| {
515                        buffer.block(|writer| {
516                            writer.push_static_str("content");
517                        })
518                    }),
519                    live: true,
520                }))
521            } else {
522                Poll::Pending
523            }
524        }
525
526        fn poll_swap(
527            mut self: Pin<&mut Self>,
528            _cx: &mut Context<'_>,
529        ) -> Poll<Result<Option<ViewSwap>>> {
530            if std::mem::replace(&mut self.resolved_swap, true) {
531                return Poll::Ready(Ok(None));
532            }
533            Self::hoist_twice();
534            Poll::Ready(Ok(Some(ViewSwap {
535                region: RegionId::new(Identity::ROOT, SiteKey::new(file!(), line!(), column!(), 0)),
536                replacement: ViewBuffer::build(|writer| {
537                    writer.push_static_str("replacement");
538                }),
539            })))
540        }
541    }
542
543    #[test]
544    fn a_keyed_part_renders_once_per_content() {
545        let mut view = pin!(ScopeView::self_contained(|| HoistView::new(OnceProbe {
546            resolved_first: false,
547            resolved_swap: false,
548        })));
549        let mut cx = Context::from_waker(Waker::noop());
550
551        // Two polls, each hoisting both keys twice: one part per key.
552        let first = loop {
553            if let Poll::Ready(first) = view.as_mut().poll_first(&mut cx) {
554                break first.unwrap();
555            }
556        };
557        assert_eq!(
558            first.content.render(&Cx::default()),
559            "<!--a--><!--b-->content"
560        );
561
562        // The swap's replacement is fresh content, so the keys hoist again.
563        let Poll::Ready(Ok(Some(swap))) = view.as_mut().poll_swap(&mut cx) else {
564            panic!("expected a swap");
565        };
566        assert_eq!(
567            swap.replacement.render(&Cx::default()),
568            "<!--a--><!--b-->replacement"
569        );
570    }
571
572    #[test]
573    fn keys_hash_by_value() {
574        assert_eq!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("dep", 1_u8)));
575        assert_ne!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("dep", 2_u8)));
576        assert_ne!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("other", 1_u8)));
577    }
578}