1use 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
26type HoistedPart = Box<dyn FnOnce(&mut PartsWriter<'_>) + Send>;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct HoistKey(u128);
35
36impl HoistKey {
37 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#[derive(Default)]
48struct Hoisted {
49 parts: Vec<HoistedPart>,
50 keys: HashSet<HoistKey>,
51}
52
53impl Hoisted {
54 fn take(&mut self) -> Vec<HoistedPart> {
56 self.keys.clear();
57 std::mem::take(&mut self.parts)
58 }
59}
60
61thread_local! {
62 static CURRENT: Cell<Option<Hoisted>> = const { Cell::new(None) };
65}
66
67#[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#[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#[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#[must_use = "the collection is uninstalled when the guard drops"]
122struct HoistGuard<'a> {
123 slot: &'a mut Hoisted,
124 prev: Option<Hoisted>,
125 _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 pub struct HoistView<V> {
155 #[pin]
156 view: V,
157 hoisted: Hoisted,
158 }
159}
160
161impl<V: View> HoistView<V> {
162 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
206fn 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 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 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 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 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 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 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 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 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 assert!(catch_unwind(|| hoist_comment("x")).is_err());
486 assert_eq!(view.hoisted.parts.len(), 1);
487 }
488
489 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 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 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}