use std::{
cell::Cell,
collections::HashSet,
hash::Hash,
marker::PhantomData,
pin::Pin,
task::{Context, Poll},
};
use pin_project_lite::pin_project;
use siphasher::sip128::{Hasher128, SipHasher13};
use topcoat_core::error::Result;
use crate::{PartsWriter, View, ViewBuffer, ViewBufferScope, ViewFirst, ViewHandle, ViewSwap};
type HoistedPart = Box<dyn FnOnce(&mut PartsWriter<'_>) + Send>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HoistKey(u128);
impl HoistKey {
pub fn new(key: impl Hash) -> Self {
let mut hasher = SipHasher13::new();
key.hash(&mut hasher);
Self(hasher.finish128().as_u128())
}
}
#[derive(Default)]
struct Hoisted {
parts: Vec<HoistedPart>,
keys: HashSet<HoistKey>,
}
impl Hoisted {
fn take(&mut self) -> Vec<HoistedPart> {
self.keys.clear();
std::mem::take(&mut self.parts)
}
}
thread_local! {
static CURRENT: Cell<Option<Hoisted>> = const { Cell::new(None) };
}
#[track_caller]
pub fn hoist(build: impl FnOnce(&mut PartsWriter<'_>) + Send + 'static) {
with_collecting(|hoisted| hoisted.parts.push(Box::new(build)));
}
#[track_caller]
pub fn hoist_once(key: HoistKey, build: impl FnOnce(&mut PartsWriter<'_>) + Send + 'static) {
with_collecting(|hoisted| {
if hoisted.keys.insert(key) {
hoisted.parts.push(Box::new(build));
}
});
}
#[track_caller]
fn with_collecting(f: impl FnOnce(&mut Hoisted)) {
let mut collected = CURRENT.take();
let Some(hoisted) = &mut collected else {
panic!(
"no view is collecting hoisted parts: `hoist` must be called while a page, layout, \
component, or shard body runs"
);
};
f(hoisted);
CURRENT.set(collected);
}
#[must_use = "the collection is uninstalled when the guard drops"]
struct HoistGuard<'a> {
slot: &'a mut Hoisted,
prev: Option<Hoisted>,
_not_send: PhantomData<*const ()>,
}
impl<'a> HoistGuard<'a> {
fn install(slot: &'a mut Hoisted) -> Self {
let prev = CURRENT.replace(Some(std::mem::take(slot)));
Self {
slot,
prev,
_not_send: PhantomData,
}
}
}
impl Drop for HoistGuard<'_> {
fn drop(&mut self) {
*self.slot = CURRENT.replace(self.prev.take()).unwrap_or_default();
}
}
pin_project! {
pub struct HoistView<V> {
#[pin]
view: V,
hoisted: Hoisted,
}
}
impl<V: View> HoistView<V> {
pub fn new(view: V) -> Self {
Self {
view,
hoisted: Hoisted::default(),
}
}
}
impl<V: View> View for HoistView<V> {
fn poll_first(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
let this = self.project();
let poll = {
let _guard = HoistGuard::install(this.hoisted);
this.view.poll_first(cx)
};
match poll {
Poll::Ready(Ok(ViewFirst { content, live })) => Poll::Ready(Ok(ViewFirst {
content: prepend(this.hoisted, content),
live,
})),
poll => poll,
}
}
fn poll_swap(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<Option<ViewSwap>>> {
let this = self.project();
let poll = {
let _guard = HoistGuard::install(this.hoisted);
this.view.poll_swap(cx)
};
match poll {
Poll::Ready(Ok(Some(ViewSwap {
region,
replacement,
}))) => Poll::Ready(Ok(Some(ViewSwap {
region,
replacement: prepend(this.hoisted, replacement),
}))),
poll => poll,
}
}
}
fn prepend(hoisted: &mut Hoisted, content: ViewHandle) -> ViewHandle {
if hoisted.parts.is_empty() {
return content;
}
let parts = hoisted.take();
let build = |writer: &mut PartsWriter<'_>| {
for part in parts {
part(writer);
}
writer.push_view_handle(content);
};
if ViewBufferScope::is_active() {
ViewBufferScope::with(|buffer| buffer.block(build))
} else {
let mut buffer = ViewBuffer::new();
buffer.block(build).seal(buffer)
}
}
#[cfg(test)]
mod tests {
use std::{
panic::{AssertUnwindSafe, catch_unwind},
pin::pin,
task::Waker,
};
use topcoat_core::context::Cx;
use super::*;
use crate::{RegionId, internal::ScopeView};
fn hoist_comment(text: &'static str) {
hoist(move |writer| {
writer.push_comment(|comment| {
comment.push_static_str(text);
});
});
}
struct Probe {
name: &'static str,
polled_first: bool,
polled_swap: bool,
}
impl Probe {
fn new(name: &'static str) -> Self {
Self {
name,
polled_first: false,
polled_swap: false,
}
}
}
impl View for Probe {
fn poll_first(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
hoist_comment(self.name);
if std::mem::replace(&mut self.polled_first, true) {
Poll::Ready(Ok(ViewFirst {
content: ViewBufferScope::with(|buffer| {
buffer.block(|writer| {
writer.push_static_str("content");
})
}),
live: true,
}))
} else {
Poll::Pending
}
}
fn poll_swap(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Option<ViewSwap>>> {
if std::mem::replace(&mut self.polled_swap, true) {
return Poll::Ready(Ok(None));
}
hoist_comment("swap");
Poll::Ready(Ok(Some(ViewSwap {
region: RegionId::next(),
replacement: ViewBuffer::build(|writer| {
writer.push_static_str("replacement");
}),
})))
}
}
fn render_first(view: impl View) -> String {
let mut view = pin!(ScopeView::new(view));
let mut cx = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(first) = view.as_mut().poll_first(&mut cx) {
return first.unwrap().content.render(&Cx::default());
}
}
}
#[test]
fn hoisting_outside_a_collecting_view_panics() {
let panic = catch_unwind(|| hoist_comment("x")).unwrap_err();
let message = panic.downcast::<&str>().expect("panics with a message");
assert!(message.contains("no view is collecting hoisted parts"));
}
#[test]
fn hoisted_parts_render_ahead_of_the_first_content() {
let html = render_first(HoistView::new(Probe::new("a")));
assert_eq!(html, "<!--a--><!--a-->content");
}
#[test]
fn hoisted_parts_render_ahead_of_the_next_swap() {
let mut view = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
"a"
))));
let mut cx = Context::from_waker(Waker::noop());
while view.as_mut().poll_first(&mut cx).is_pending() {}
let Poll::Ready(Ok(Some(swap))) = view.as_mut().poll_swap(&mut cx) else {
panic!("expected a swap");
};
assert_eq!(
swap.replacement.render(&Cx::default()),
"<!--swap-->replacement"
);
assert!(matches!(
view.as_mut().poll_swap(&mut cx),
Poll::Ready(Ok(None))
));
}
#[test]
fn a_view_that_hoists_nothing_passes_its_content_through() {
struct Plain;
impl View for Plain {
fn poll_first(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
Poll::Ready(Ok(ViewFirst {
content: ViewHandle::empty(),
live: false,
}))
}
fn poll_swap(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Option<ViewSwap>>> {
Poll::Ready(Ok(None))
}
}
assert_eq!(render_first(HoistView::new(Plain)), "");
}
#[test]
fn interleaved_siblings_each_collect_their_own_parts() {
let mut first = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
"a"
))));
let mut second = pin!(ScopeView::self_contained(|| HoistView::new(Probe::new(
"b"
))));
let mut cx = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll_first(&mut cx).is_pending());
assert!(second.as_mut().poll_first(&mut cx).is_pending());
let Poll::Ready(Ok(first)) = first.as_mut().poll_first(&mut cx) else {
panic!("expected content");
};
let Poll::Ready(Ok(second)) = second.as_mut().poll_first(&mut cx) else {
panic!("expected content");
};
assert_eq!(
first.content.render(&Cx::default()),
"<!--a--><!--a-->content"
);
assert_eq!(
second.content.render(&Cx::default()),
"<!--b--><!--b-->content"
);
}
#[test]
fn a_nested_view_collects_its_own_parts_and_restores_the_outer_ones() {
struct Outer<V> {
inner: Pin<Box<V>>,
}
impl<V: View> View for Outer<V> {
fn poll_first(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<ViewFirst>> {
hoist_comment("outer");
let inner = std::task::ready!(self.inner.as_mut().poll_first(cx))?;
hoist_comment("outer again");
Poll::Ready(Ok(ViewFirst {
content: ViewBufferScope::with(|buffer| {
buffer.block(|writer| {
writer.push_static_str("[");
writer.push_view_handle(inner.content);
writer.push_static_str("]");
})
}),
live: false,
}))
}
fn poll_swap(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Option<ViewSwap>>> {
Poll::Ready(Ok(None))
}
}
let html = render_first(HoistView::new(Outer {
inner: Box::pin(HoistView::new(Probe::new("inner"))),
}));
assert_eq!(
html,
"<!--outer--><!--outer--><!--outer again-->[<!--inner--><!--inner-->content]"
);
}
#[test]
fn the_collection_is_restored_when_a_poll_panics() {
struct Boom;
impl View for Boom {
fn poll_first(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
hoist_comment("lost");
panic!("boom")
}
fn poll_swap(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Option<ViewSwap>>> {
panic!("boom")
}
}
let mut view = pin!(HoistView::new(Boom));
let result = catch_unwind(AssertUnwindSafe(|| {
let mut cx = Context::from_waker(Waker::noop());
let _ = view.as_mut().poll_first(&mut cx);
}));
assert!(result.is_err());
assert!(catch_unwind(|| hoist_comment("x")).is_err());
assert_eq!(view.hoisted.parts.len(), 1);
}
struct OnceProbe {
resolved_first: bool,
resolved_swap: bool,
}
impl OnceProbe {
fn hoist_twice() {
for text in ["a", "b", "a", "b"] {
hoist_once(HoistKey::new(("comment", text)), move |writer| {
writer.push_comment(|comment| {
comment.push_static_str(text);
});
});
}
}
}
impl View for OnceProbe {
fn poll_first(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<ViewFirst>> {
Self::hoist_twice();
if std::mem::replace(&mut self.resolved_first, true) {
Poll::Ready(Ok(ViewFirst {
content: ViewBufferScope::with(|buffer| {
buffer.block(|writer| {
writer.push_static_str("content");
})
}),
live: true,
}))
} else {
Poll::Pending
}
}
fn poll_swap(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Option<ViewSwap>>> {
if std::mem::replace(&mut self.resolved_swap, true) {
return Poll::Ready(Ok(None));
}
Self::hoist_twice();
Poll::Ready(Ok(Some(ViewSwap {
region: RegionId::next(),
replacement: ViewBuffer::build(|writer| {
writer.push_static_str("replacement");
}),
})))
}
}
#[test]
fn a_keyed_part_renders_once_per_content() {
let mut view = pin!(ScopeView::self_contained(|| HoistView::new(OnceProbe {
resolved_first: false,
resolved_swap: false,
})));
let mut cx = Context::from_waker(Waker::noop());
let first = loop {
if let Poll::Ready(first) = view.as_mut().poll_first(&mut cx) {
break first.unwrap();
}
};
assert_eq!(
first.content.render(&Cx::default()),
"<!--a--><!--b-->content"
);
let Poll::Ready(Ok(Some(swap))) = view.as_mut().poll_swap(&mut cx) else {
panic!("expected a swap");
};
assert_eq!(
swap.replacement.render(&Cx::default()),
"<!--a--><!--b-->replacement"
);
}
#[test]
fn keys_hash_by_value() {
assert_eq!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("dep", 1_u8)));
assert_ne!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("dep", 2_u8)));
assert_ne!(HoistKey::new(("dep", 1_u8)), HoistKey::new(("other", 1_u8)));
}
}