From a02c230299fcd58d0d45071614f6d2b560e74590 Mon Sep 17 00:00:00 2001 From: Pete Hunt <239742+petehunt@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:53:17 -0400 Subject: [PATCH 1/2] feat(view): render component trees concurrently --- Cargo.lock | 1 + crates/topcoat-view/Cargo.toml | 1 + .../grammar/src/view/component.rs | 54 ++- .../grammar/src/view/view_writer.rs | 336 ++++++++++++++---- crates/topcoat-view/macro/docs/view.md | 4 + crates/topcoat-view/macro/tests/component.rs | 132 +++++++ crates/topcoat-view/src/lib.rs | 3 + crates/topcoat-view/src/view.rs | 19 +- crates/topcoat-view/src/view_tree.rs | 97 +++++ 9 files changed, 560 insertions(+), 87 deletions(-) create mode 100644 crates/topcoat-view/src/view_tree.rs diff --git a/Cargo.lock b/Cargo.lock index 2ae13e609..4af78b91e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3194,6 +3194,7 @@ name = "topcoat-view" version = "0.5.0" dependencies = [ "criterion", + "futures-util", "http", "itoa", "memchr", diff --git a/crates/topcoat-view/Cargo.toml b/crates/topcoat-view/Cargo.toml index 9df451824..fbd5c4ac1 100644 --- a/crates/topcoat-view/Cargo.toml +++ b/crates/topcoat-view/Cargo.toml @@ -18,6 +18,7 @@ http = [ [dependencies] topcoat-core.workspace = true +futures-util.workspace = true http = { workspace = true, optional = true } itoa.workspace = true memchr.workspace = true diff --git a/crates/topcoat-view/grammar/src/view/component.rs b/crates/topcoat-view/grammar/src/view/component.rs index ad06fa849..28a350b2b 100644 --- a/crates/topcoat-view/grammar/src/view/component.rs +++ b/crates/topcoat-view/grammar/src/view/component.rs @@ -8,11 +8,14 @@ use syn::{ spanned::Spanned, token::Paren, }; -use topcoat_core_grammar::{ParseOption, paths::topcoat_view}; +use topcoat_core_grammar::{ + ParseOption, + paths::{topcoat_error, topcoat_view}, +}; use crate::{ template::RuntimeExpr, - view::{ExprKind, Nodes, ViewWriter, WriteView}, + view::{Nodes, ViewWriter, WriteView}, }; /// A component invocation, written as `path(name: value, ..., child_node child_node ...)`. @@ -84,29 +87,46 @@ impl WriteView for Component { for child in &self.children { child.write(&mut child_writer); } - let child = child_writer.into_token_stream(); - quote_spanned! {self.paren_token.span.span()=> - .child(#child) - } + child_writer.into_token_stream() }); - writer.write_expr( - ExprKind::View, + let component = if let Some(child) = child { quote_spanned! {self.paren_token.span.span()=> - { + async { use #topcoat_view::Component; - let props = #name::props_builder()#(#setters)*#child.build(); + let __child_slot = __ViewSlot::new(); + let props = #name::props_builder() + #(#setters)* + .child(__child_slot.view()) + .build(); + let __child = async { + __child_slot.fill(#child); + ::core::result::Result::<(), #topcoat_error::Error>::Ok(()) + }; // The marker is built via `Default` so the same construction // works for both unit-struct and generic (`PhantomData`) markers. #[allow(clippy::default_constructed_unit_structs)] - Component::render( - #name::default(), - __cx, - props, - ).await? + let __component = Component::render(#name::default(), __cx, props); + let (__child_result, __component_result) = + __join(__child, __component).await; + __child_result?; + __component_result } - }, - ); + } + } else { + quote_spanned! {self.paren_token.span.span()=> + async { + use #topcoat_view::Component; + let props = #name::props_builder()#(#setters)*.build(); + // The marker is built via `Default` so the same construction + // works for both unit-struct and generic (`PhantomData`) markers. + #[allow(clippy::default_constructed_unit_structs)] + Component::render(#name::default(), __cx, props).await + } + } + }; + + writer.write_component(component); } } diff --git a/crates/topcoat-view/grammar/src/view/view_writer.rs b/crates/topcoat-view/grammar/src/view/view_writer.rs index f497d720d..281b3ba09 100644 --- a/crates/topcoat-view/grammar/src/view/view_writer.rs +++ b/crates/topcoat-view/grammar/src/view/view_writer.rs @@ -70,6 +70,11 @@ impl ViewWriter { self.chunks.push(Chunk::Expr { kind, tokens }); } + pub fn write_component(&mut self, tokens: TokenStream) { + self.flush(); + self.chunks.push(Chunk::Component { tokens }); + } + pub fn local_binding(&mut self, pat: &Pat, expr: &Expr) { self.flush(); self.chunks.push(Chunk::Local { @@ -130,72 +135,8 @@ impl ViewWriter { && let Chunk::Static { string } = &self.chunks[0] { quote! { #topcoat_view::View::unescaped_unchecked(#string) } - } else { - fn build_parts(chunks: &[Chunk]) -> TokenStream { - let mut output = TokenStream::new(); - for chunk in chunks { - match chunk { - Chunk::Static { string } => { - let helper = ExprKind::Unescaped.helper(); - let tokens = quote! { #string }; - quote! { #helper(__cx, &mut __parts, #tokens); } - } - Chunk::Expr { kind, tokens } => { - let helper = kind.helper(); - quote! { #helper(__cx, &mut __parts, #tokens); } - } - Chunk::Local { pat, expr } => { - quote! { let #pat = #expr; } - } - Chunk::Statement { tokens } => { - quote! { #tokens } - } - Chunk::If { - expr, - then_branch: then, - else_branch: r#else, - } => { - let then_branch = build_parts(&then.chunks); - let else_branch = build_parts(&r#else.chunks); - let else_branch = (!r#else.chunks.is_empty()) - .then(|| quote! { else { #else_branch } }); - quote! { - if #expr { - #then_branch - } - #else_branch - } - } - Chunk::For { pat, expr, body } => { - let body = build_parts(&body.chunks); - quote! { - for #pat in #expr { - #body - } - } - } - Chunk::Match { expr, arms } => { - let arm_tokens = arms.iter().map(|arm| { - let pat = &arm.pat; - let guard = arm.guard.as_ref().map(|g| quote! { if #g }); - let body = build_parts(&arm.body.chunks); - quote! { - #pat #guard => { #body } - } - }); - quote! { - match #expr { - #(#arm_tokens,)* - } - } - } - } - .to_tokens(&mut output); - } - output - } - - let statements = build_parts(&self.chunks); + } else if !contains_component(&self.chunks) { + let statements = build_ready_parts(&self.chunks); quote! {{ use #topcoat_view::internal::*; @@ -203,6 +144,13 @@ impl ViewWriter { #statements #topcoat_view::View::new(__parts) }} + } else { + let future = build_tree_future(&self.chunks); + return if self.nested { + quote! { (#future).await? } + } else { + quote! { (#future).await } + }; } }; @@ -214,6 +162,234 @@ impl ViewWriter { } } +fn contains_component(chunks: &[Chunk]) -> bool { + chunks.iter().any(|chunk| match chunk { + Chunk::Component { .. } => true, + Chunk::For { body, .. } => contains_component(&body.chunks), + Chunk::If { + then_branch, + else_branch, + .. + } => contains_component(&then_branch.chunks) || contains_component(&else_branch.chunks), + Chunk::Match { arms, .. } => arms.iter().any(|arm| contains_component(&arm.body.chunks)), + Chunk::Static { .. } + | Chunk::Expr { .. } + | Chunk::Local { .. } + | Chunk::Statement { .. } => false, + }) +} + +fn build_tree_future(chunks: &[Chunk]) -> TokenStream { + // Declare locals before the tree whose futures may borrow them. + let prologue_len = chunks + .iter() + .take_while(|chunk| matches!(chunk, Chunk::Local { .. } | Chunk::Statement { .. })) + .count(); + let prologue_chunks = &chunks[..prologue_len]; + let prologue = build_ready_parts(prologue_chunks); + let prologue_view = prologue_chunks + .iter() + .any(|chunk| matches!(chunk, Chunk::Statement { .. })) + .then(|| { + quote! { + __tree.push_view(#topcoat_view::View::new(::core::mem::take(&mut __parts))); + } + }); + let (statements, parts_dirty) = build_tree_statements(&chunks[prologue_len..]); + let flush = parts_dirty.then(|| { + quote! { + __tree.push_view(#topcoat_view::View::new(__parts)); + } + }); + + quote! { + async { + use #topcoat_view::internal::*; + let mut __parts = #topcoat_view::ViewParts::new(); + #prologue + let mut __tree = __ViewTree::new(); + #prologue_view + #statements + #flush + __tree.resolve().await + } + } +} + +fn build_tree_statements(chunks: &[Chunk]) -> (TokenStream, bool) { + let mut output = TokenStream::new(); + let mut parts_dirty = false; + + for (index, chunk) in chunks.iter().enumerate() { + match chunk { + Chunk::Static { string } => { + parts_dirty = true; + let helper = ExprKind::Unescaped.helper(); + quote! { #helper(__cx, &mut __parts, #string); } + } + Chunk::Expr { kind, tokens } => { + parts_dirty = true; + let helper = kind.helper(); + quote! { #helper(__cx, &mut __parts, #tokens); } + } + Chunk::Component { tokens } => { + let flush = flush_tree_parts(&mut parts_dirty); + quote! { + #flush + __tree.push_future(#tokens); + } + } + Chunk::Local { .. } | Chunk::Statement { .. } => { + let flush = flush_tree_parts(&mut parts_dirty); + // Keep later locals inside a subtree so they outlive its pending nodes. + let remainder = build_tree_future(&chunks[index..]); + quote! { + #flush + __tree.push_future(#remainder); + } + .to_tokens(&mut output); + return (output, false); + } + Chunk::If { + expr, + then_branch, + else_branch, + } if contains_component(&then_branch.chunks) + || contains_component(&else_branch.chunks) => + { + let then_branch = build_tree_future(&then_branch.chunks); + let else_branch = build_tree_future(&else_branch.chunks); + let flush = flush_tree_parts(&mut parts_dirty); + quote! { + #flush + __tree.push_future(async { + if #expr { + (#then_branch).await + } else { + (#else_branch).await + } + }); + } + } + Chunk::For { pat, expr, body } if contains_component(&body.chunks) => { + let body = build_tree_future(&body.chunks); + let flush = flush_tree_parts(&mut parts_dirty); + quote! { + #flush + __tree.push_future(async { + let __iterations = (#expr) + .into_iter() + .map(async |#pat| (#body).await); + let mut __iteration_parts = #topcoat_view::ViewParts::new(); + for __iteration in __join_all(__iterations).await { + __view(__cx, &mut __iteration_parts, __iteration?); + } + ::core::result::Result::< + #topcoat_view::View, + #topcoat_error::Error, + >::Ok(#topcoat_view::View::new(__iteration_parts)) + }); + } + } + Chunk::Match { expr, arms } + if arms.iter().any(|arm| contains_component(&arm.body.chunks)) => + { + let arm_tokens = arms.iter().map(|arm| { + let pat = &arm.pat; + let guard = arm.guard.as_ref().map(|guard| quote! { if #guard }); + let body = build_tree_future(&arm.body.chunks); + quote! { #pat #guard => (#body).await } + }); + let flush = flush_tree_parts(&mut parts_dirty); + quote! { + #flush + __tree.push_future(async { + match #expr { + #(#arm_tokens,)* + } + }); + } + } + Chunk::If { .. } | Chunk::For { .. } | Chunk::Match { .. } => { + parts_dirty = true; + build_ready_parts(core::slice::from_ref(chunk)) + } + } + .to_tokens(&mut output); + } + + (output, parts_dirty) +} + +fn flush_tree_parts(parts_dirty: &mut bool) -> TokenStream { + if *parts_dirty { + *parts_dirty = false; + quote! { + __tree.push_view(#topcoat_view::View::new(::core::mem::take(&mut __parts))); + } + } else { + TokenStream::new() + } +} + +fn build_ready_parts(chunks: &[Chunk]) -> TokenStream { + let mut output = TokenStream::new(); + for chunk in chunks { + match chunk { + Chunk::Static { string } => { + let helper = ExprKind::Unescaped.helper(); + quote! { #helper(__cx, &mut __parts, #string); } + } + Chunk::Expr { kind, tokens } => { + let helper = kind.helper(); + quote! { #helper(__cx, &mut __parts, #tokens); } + } + Chunk::Component { .. } => unreachable!(), + Chunk::Local { pat, expr } => quote! { let #pat = #expr; }, + Chunk::Statement { tokens } => quote! { #tokens }, + Chunk::If { + expr, + then_branch, + else_branch, + } => { + let then_branch = build_ready_parts(&then_branch.chunks); + let else_branch = build_ready_parts(&else_branch.chunks); + let else_branch = + (!else_branch.is_empty()).then(|| quote! { else { #else_branch } }); + quote! { + if #expr { + #then_branch + } + #else_branch + } + } + Chunk::For { pat, expr, body } => { + let body = build_ready_parts(&body.chunks); + quote! { + for #pat in #expr { + #body + } + } + } + Chunk::Match { expr, arms } => { + let arm_tokens = arms.iter().map(|arm| { + let pat = &arm.pat; + let guard = arm.guard.as_ref().map(|guard| quote! { if #guard }); + let body = build_ready_parts(&arm.body.chunks); + quote! { #pat #guard => { #body } } + }); + quote! { + match #expr { + #(#arm_tokens,)* + } + } + } + } + .to_tokens(&mut output); + } + output +} + /// Identifies which `internal` helper a [`Chunk::Expr`] should be wrapped in /// when emitted, so the generated code uses the matching `__*` function and /// the corresponding `*ViewParts` trait. @@ -221,7 +397,6 @@ impl ViewWriter { pub(crate) enum ExprKind { Unescaped, Node, - View, ElementName, Attribute, AttributeUnescaped, @@ -235,7 +410,6 @@ impl ExprKind { let name = match self { Self::Unescaped => "__unescaped", Self::Node => "__node", - Self::View => "__view", Self::ElementName => "__element_name", Self::Attribute => "__attribute", Self::AttributeUnescaped => "__attribute_unescaped", @@ -255,6 +429,9 @@ enum Chunk { kind: ExprKind, tokens: TokenStream, }, + Component { + tokens: TokenStream, + }, Local { pat: Pat, expr: Box, @@ -364,6 +541,28 @@ mod tests { assert!(out.contains("__unescaped (__cx , & mut __parts , \"

\")")); } + #[test] + fn components_across_control_flow_build_a_future_tree() { + let mut writer = ViewWriter::new(); + writer.write_component(quote! { first() }); + writer.if_else(&syn::parse_quote!(cond), |then_branch, _| { + then_branch.write_component(quote! { second() }); + }); + writer.for_loop( + &syn::parse_quote!(item), + &syn::parse_quote!(items), + |body| { + body.write_component(quote! { row(item) }); + }, + ); + let out = rendered(writer); + + assert!(out.contains("__ViewTree :: new")); + assert!(out.contains("push_future (first ())")); + assert!(out.contains("if cond")); + assert!(out.contains("__join_all")); + } + #[test] fn if_else_renders_both_branches() { let mut writer = ViewWriter::new(); @@ -434,7 +633,6 @@ mod tests { for (kind, expected) in [ (ExprKind::Unescaped, "__unescaped"), (ExprKind::Node, "__node"), - (ExprKind::View, "__view"), (ExprKind::ElementName, "__element_name"), (ExprKind::Attribute, "__attribute"), (ExprKind::AttributeUnescaped, "__attribute_unescaped"), diff --git a/crates/topcoat-view/macro/docs/view.md b/crates/topcoat-view/macro/docs/view.md index 97b748f73..2f1746451 100644 --- a/crates/topcoat-view/macro/docs/view.md +++ b/crates/topcoat-view/macro/docs/view.md @@ -345,6 +345,10 @@ view! { See how to define components in the [`component`] macro guide. +The macro collects component calls into an unresolved view tree, then polls every independent component in that tree concurrently. This includes components selected by `if` and `match`, components separated by static HTML, and every iteration of a `for` loop. Completed views are inserted in source order, regardless of completion order. Components in branches that are not selected do not run. + +Child nodes use an internal placeholder while the component renders, allowing components inside the child and the parent to run concurrently. The placeholder is filled before the completed view can be rendered. + # Boolean And Conditional Attributes [Boolean HTML attributes](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML) such as `disabled`, `required`, and `checked` are true when the attribute is present and false when it is absent. HTML expects a present boolean attribute to have an empty value. diff --git a/crates/topcoat-view/macro/tests/component.rs b/crates/topcoat-view/macro/tests/component.rs index a4d0bca38..215aeff53 100644 --- a/crates/topcoat-view/macro/tests/component.rs +++ b/crates/topcoat-view/macro/tests/component.rs @@ -1,3 +1,5 @@ +use std::sync::{Arc, Mutex}; + use topcoat::{ Result, context::Cx, @@ -95,6 +97,136 @@ async fn component_can_call_other_components_and_forward_child_views() { assert!(html.contains("inner")); } +#[component] +async fn concurrent_slot(log: Arc>>, label: &'static str) -> Result { + log.lock().unwrap().push(format!("enter {label}")); + tokio::task::yield_now().await; + log.lock().unwrap().push(format!("exit {label}")); + + view! { (label) } +} + +#[component] +async fn concurrent_group( + log: Arc>>, + first: &'static str, + second: &'static str, + child: View, +) -> Result { + view! { +
+ concurrent_slot(log: Arc::clone(&log), label: first) + (child) + concurrent_slot(log: Arc::clone(&log), label: second) +
+ } +} + +#[tokio::test] +async fn components_across_control_flow_render_concurrently_in_source_order() { + let cx = empty_cx(); + let __cx = &cx; + let log = Arc::new(Mutex::new(Vec::new())); + let result: Result = view! { +
+ concurrent_slot(log: Arc::clone(&log), label: "a") + if true { +
concurrent_slot(log: Arc::clone(&log), label: "b")
+ } else { + concurrent_slot(log: Arc::clone(&log), label: "skipped-if") + } + match Some("c") { + Some(label) => concurrent_slot(log: Arc::clone(&log), label: label), + None => concurrent_slot(log: Arc::clone(&log), label: "skipped-match"), + } + for label in ["d", "e"] { + + } + concurrent_slot(log: Arc::clone(&log), label: "f") +
+ }; + + assert_eq!( + *log.lock().unwrap(), + [ + "enter a", "enter b", "enter c", "enter d", "enter e", "enter f", "exit a", "exit b", + "exit c", "exit d", "exit e", "exit f", + ], + ); + assert_eq!( + result.unwrap().render(__cx), + "
a
b
cf
", + ); +} + +#[tokio::test] +async fn component_loop_iterations_render_concurrently_in_iterator_order() { + let cx = empty_cx(); + let __cx = &cx; + let log = Arc::new(Mutex::new(Vec::new())); + let result: Result = view! { +
+ for label in ["a", "b", "c"] { + concurrent_slot(log: Arc::clone(&log), label: label) + } +
+ }; + + assert_eq!( + *log.lock().unwrap(), + [ + "enter a", "enter b", "enter c", "exit a", "exit b", "exit c" + ], + ); + assert_eq!( + result.unwrap().render(__cx), + "
abc
", + ); +} + +#[tokio::test] +async fn components_in_parent_and_child_views_render_concurrently() { + let cx = empty_cx(); + let __cx = &cx; + let log = Arc::new(Mutex::new(Vec::new())); + let result: Result = view! { + concurrent_group( + log: Arc::clone(&log), + first: "parent-a", + second: "parent-b", + concurrent_group( + log: Arc::clone(&log), + first: "child-a", + second: "child-b", + concurrent_slot(log: Arc::clone(&log), label: "grandchild-a") + concurrent_slot(log: Arc::clone(&log), label: "grandchild-b") + ) + ) + }; + + assert_eq!( + *log.lock().unwrap(), + [ + "enter grandchild-a", + "enter grandchild-b", + "enter child-a", + "enter child-b", + "enter parent-a", + "enter parent-b", + "exit grandchild-a", + "exit grandchild-b", + "exit child-a", + "exit child-b", + "exit parent-a", + "exit parent-b", + ], + ); + assert_eq!( + result.unwrap().render(__cx), + "
parent-a
child-agrandchild-agrandchild-bchild-b
parent-b
", + ); +} + #[component] async fn no_args_component() -> Result { view! {

"static"

} diff --git a/crates/topcoat-view/src/lib.rs b/crates/topcoat-view/src/lib.rs index b940f8985..edf94e0a1 100644 --- a/crates/topcoat-view/src/lib.rs +++ b/crates/topcoat-view/src/lib.rs @@ -12,6 +12,7 @@ mod props; pub mod svg; mod unescaped; mod view; +mod view_tree; pub use attribute::*; pub use class::*; @@ -28,8 +29,10 @@ pub use view::*; /// Macro helpers to shorten the generated source code. #[doc(hidden)] pub mod internal { + pub use futures_util::future::{join as __join, join_all as __join_all}; use topcoat_core::context::Cx; + pub use crate::view_tree::{ViewSlot as __ViewSlot, ViewTree as __ViewTree}; use crate::{ Attribute, AttributeKeyViewParts, AttributeValueViewParts, AttributeViewParts, ElementNameViewParts, HtmlContext, NodeViewParts, PartsWriter, Unescaped, View, ViewParts, diff --git a/crates/topcoat-view/src/view.rs b/crates/topcoat-view/src/view.rs index 25bdf544b..0265dd094 100644 --- a/crates/topcoat-view/src/view.rs +++ b/crates/topcoat-view/src/view.rs @@ -1,5 +1,8 @@ use core::{fmt, fmt::Write as _}; -use std::borrow::Cow; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; #[cfg(feature = "http")] use http::{HeaderMap, StatusCode}; @@ -208,6 +211,9 @@ pub enum ViewPart { inner: Box<[ViewPart]>, size_hint: usize, }, + /// A framework-managed slot filled before the completed view is rendered. + #[doc(hidden)] + Slot(Arc>>), /// A response status code recorded at render time; renders no content. #[cfg(feature = "http")] #[non_exhaustive] @@ -277,6 +283,12 @@ impl ViewPart { part.render(cx, f); } } + Self::Slot(inner) => inner + .lock() + .unwrap() + .as_ref() + .expect("view slot must be filled before rendering") + .render(cx, f), #[cfg(feature = "http")] Self::StatusCode(status_code) => f.record_status_code(*status_code), #[cfg(feature = "http")] @@ -322,6 +334,11 @@ impl ViewPart { _ => value.len() + value.len() / 8, }, Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint, + Self::Slot(inner) => inner + .lock() + .unwrap() + .as_ref() + .map_or(0, ViewPart::size_hint), #[cfg(feature = "http")] Self::StatusCode(_) | Self::Headers(_) => 0, } diff --git a/crates/topcoat-view/src/view_tree.rs b/crates/topcoat-view/src/view_tree.rs new file mode 100644 index 000000000..5749c9907 --- /dev/null +++ b/crates/topcoat-view/src/view_tree.rs @@ -0,0 +1,97 @@ +use core::{future::Future, pin::Pin}; +use std::sync::{Arc, Mutex}; + +use futures_util::future::join_all; +use topcoat_core::error::{Error, Result}; + +use crate::{View, ViewPart, ViewParts}; + +type ViewFuture<'a> = Pin> + Send + 'a>>; + +enum ViewTreeNode<'a> { + Ready(View), + Pending(ViewFuture<'a>), +} + +/// A view placeholder filled by generated component code before rendering. +#[doc(hidden)] +#[derive(Clone, Debug, Default)] +pub struct ViewSlot { + part: Arc>>, +} + +impl ViewSlot { + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[inline] + #[must_use] + pub fn view(&self) -> View { + let mut parts = ViewParts::new(); + parts.push_part(ViewPart::Slot(Arc::clone(&self.part))); + View::new(parts) + } + + /// Fills the placeholder with its completed child view. + /// + /// # Panics + /// + /// Panics if the slot was already filled or its lock was poisoned. + #[inline] + pub fn fill(&self, view: View) { + let previous = self.part.lock().unwrap().replace(view.into_part()); + assert!(previous.is_none(), "view slot must only be filled once"); + } +} + +/// Collects ready view segments and component futures before resolving them. +/// +/// This is plumbing for generated `view!` code. +#[doc(hidden)] +#[derive(Default)] +pub struct ViewTree<'a> { + nodes: Vec>, +} + +impl<'a> ViewTree<'a> { + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn push_view(&mut self, view: View) { + self.nodes.push(ViewTreeNode::Ready(view)); + } + + #[inline] + pub fn push_future(&mut self, future: impl Future> + Send + 'a) { + self.nodes.push(ViewTreeNode::Pending(Box::pin(future))); + } + + /// Resolves pending nodes and combines every view in tree order. + /// + /// # Errors + /// + /// Returns the first component error in tree order after every pending + /// node completes. + pub async fn resolve(self) -> Result { + let views = join_all(self.nodes.into_iter().map(|node| async move { + match node { + ViewTreeNode::Ready(view) => Ok(view), + ViewTreeNode::Pending(future) => future.await, + } + })) + .await; + + let mut parts = ViewParts::new(); + for view in views { + parts.push_view(view?); + } + Ok(View::new(parts)) + } +} From 6e4214b3b166b68ed59b72be97bc57b840699521 Mon Sep 17 00:00:00 2001 From: Pete Hunt <239742+petehunt@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:37:49 -0400 Subject: [PATCH 2/2] feat(view): stream deferred views --- Cargo.lock | 8 + Cargo.toml | 1 + crates/topcoat-core/src/context.rs | 72 +++++- .../topcoat-core/src/context/context_map.rs | 2 + crates/topcoat-router/Cargo.toml | 3 +- crates/topcoat-router/src/page.rs | 2 + crates/topcoat-router/src/response.rs | 237 +++++++++++++++++- crates/topcoat-view/grammar/src/view.rs | 2 + .../grammar/src/view/component.rs | 14 +- .../topcoat-view/grammar/src/view/deferred.rs | 95 +++++++ crates/topcoat-view/grammar/src/view/node.rs | 13 +- .../pretty/deferred_component.expected | 7 + .../fixtures/pretty/deferred_component.input | 1 + crates/topcoat-view/grammar/tests/pretty.rs | 1 + crates/topcoat-view/macro/docs/view.md | 25 ++ crates/topcoat-view/macro/tests/component.rs | 73 ++++++ crates/topcoat-view/src/deferred.rs | 110 ++++++++ crates/topcoat-view/src/format.rs | 19 +- crates/topcoat-view/src/lib.rs | 2 + crates/topcoat-view/src/view.rs | 44 +++- crates/topcoat/browser/defer.js | 46 ++++ crates/topcoat/docs/view.md | 60 +++++ crates/topcoat/src/view.rs | 53 ++++ examples/deferred-view/Cargo.toml | 13 + examples/deferred-view/README.md | 11 + examples/deferred-view/src/main.rs | 81 ++++++ 26 files changed, 970 insertions(+), 25 deletions(-) create mode 100644 crates/topcoat-view/grammar/src/view/deferred.rs create mode 100644 crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.expected create mode 100644 crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.input create mode 100644 crates/topcoat-view/src/deferred.rs create mode 100644 crates/topcoat/browser/defer.js create mode 100644 examples/deferred-view/Cargo.toml create mode 100644 examples/deferred-view/README.md create mode 100644 examples/deferred-view/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 4af78b91e..128e2455b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "deferred-view" +version = "0.5.0" +dependencies = [ + "tokio", + "topcoat", +] + [[package]] name = "deranged" version = "0.5.8" diff --git a/Cargo.toml b/Cargo.toml index e2dbeeff7..ee3edd4a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "examples/context", "examples/cookie", "examples/datastar", + "examples/deferred-view", "examples/font", "examples/hello-world", "examples/htmx", diff --git a/crates/topcoat-core/src/context.rs b/crates/topcoat-core/src/context.rs index a8862aea5..c43dd69d0 100644 --- a/crates/topcoat-core/src/context.rs +++ b/crates/topcoat-core/src/context.rs @@ -19,9 +19,9 @@ use crate::{abort::AbortStore, memoize::MemoizeCache}; pub struct Cx { id: CxId, app_context: Arc, - request_context: ContextMap, - memoize_cache: MemoizeCache, - abort_store: AbortStore, + request_context: Arc, + memoize_cache: Arc, + abort_store: Arc, } impl Cx { @@ -30,17 +30,59 @@ impl Cx { Self { id: CxId::new(), app_context, - request_context, - memoize_cache: MemoizeCache::new(), - abort_store: AbortStore::new(), + request_context: Arc::new(request_context), + memoize_cache: Arc::new(MemoizeCache::new()), + abort_store: Arc::new(AbortStore::new()), } } /// Returns this context's unique [`CxId`]. #[inline] + #[must_use] pub fn id(&self) -> CxId { self.id } + + /// Returns an owned handle to this request context. + /// + /// The handle keeps request and app context values alive while a response + /// stream is still producing deferred views. + #[must_use] + pub fn handle(&self) -> CxHandle { + CxHandle(Self { + id: self.id, + app_context: Arc::clone(&self.app_context), + request_context: Arc::clone(&self.request_context), + memoize_cache: Arc::clone(&self.memoize_cache), + abort_store: Arc::clone(&self.abort_store), + }) + } +} + +/// An owned handle to a request [`Cx`]. +/// +/// It dereferences to `Cx`, so context helpers accept `&handle`. +#[derive(Debug)] +pub struct CxHandle(Cx); + +impl Clone for CxHandle { + fn clone(&self) -> Self { + self.0.handle() + } +} + +impl AsRef for CxHandle { + fn as_ref(&self) -> &Cx { + &self.0 + } +} + +impl Deref for CxHandle { + type Target = Cx; + + fn deref(&self) -> &Self::Target { + &self.0 + } } /// Assembles the request context for an in-flight request. @@ -71,11 +113,17 @@ impl CxBuilder { /// /// A type can hold only one value at a time, so registering a type that is /// already present replaces it and hands back the displaced value. + /// + /// # Panics + /// + /// Panics if the request context was shared before the builder finished. pub fn insert(&mut self, value: T) -> Option where T: Any + Send + Sync, { - self.cx.request_context.insert(value) + Arc::get_mut(&mut self.cx.request_context) + .expect("request context was shared before it was built") + .insert(value) } /// Returns `true` if a value of type `T` has been registered on the request @@ -100,12 +148,18 @@ impl CxBuilder { /// Returns a mutable reference to the request context value of type `T`, or /// `None` if no such value has been registered. + /// + /// # Panics + /// + /// Panics if the request context was shared before the builder finished. #[must_use] pub fn get_mut(&mut self) -> Option<&mut T> where T: Any + Send + Sync, { - self.cx.request_context.get_mut::() + Arc::get_mut(&mut self.cx.request_context) + .expect("request context was shared before it was built") + .get_mut::() } /// Consumes the builder, returning the finished [`Cx`]. @@ -169,12 +223,14 @@ impl CxTestBuilder { #[inline] #[doc(hidden)] +#[must_use] pub fn memoize_cache(cx: &Cx) -> &MemoizeCache { &cx.memoize_cache } #[inline] #[doc(hidden)] +#[must_use] pub fn abort_store(cx: &Cx) -> &AbortStore { &cx.abort_store } diff --git a/crates/topcoat-core/src/context/context_map.rs b/crates/topcoat-core/src/context/context_map.rs index c281d0998..299e6b8a3 100644 --- a/crates/topcoat-core/src/context/context_map.rs +++ b/crates/topcoat-core/src/context/context_map.rs @@ -66,6 +66,7 @@ where /// } /// ``` #[track_caller] +#[must_use] pub fn app_context(cx: &Cx) -> &T where T: Any + Send + Sync, @@ -131,6 +132,7 @@ where /// } /// ``` #[track_caller] +#[must_use] pub fn request_context(cx: &Cx) -> &T where T: Any + Send + Sync, diff --git a/crates/topcoat-router/Cargo.toml b/crates/topcoat-router/Cargo.toml index cd96975c3..75e799968 100644 --- a/crates/topcoat-router/Cargo.toml +++ b/crates/topcoat-router/Cargo.toml @@ -19,7 +19,6 @@ discover = [ "dep:inventory", ] multipart = [ - "dep:futures-util", "dep:multer", ] serve = [ @@ -56,7 +55,7 @@ bytes.workspace = true form_urlencoded.workspace = true futures-core.workspace = true futures-sink = { workspace = true, optional = true } -futures-util = { workspace = true, optional = true } +futures-util.workspace = true heck.workspace = true http.workspace = true http-body.workspace = true diff --git a/crates/topcoat-router/src/page.rs b/crates/topcoat-router/src/page.rs index 6d9374f27..c691c0cb0 100644 --- a/crates/topcoat-router/src/page.rs +++ b/crates/topcoat-router/src/page.rs @@ -78,6 +78,7 @@ impl PageFn { } /// Renders the page, returning a [`Result`]. + #[must_use] pub fn render<'cx>( &self, cx: &'cx Cx, @@ -124,6 +125,7 @@ impl LayoutFn { } /// Renders the layout, embedding the given child content [`Result`]`<`[`View`]`>` as its slot. + #[must_use] pub fn render<'cx>( &self, cx: &'cx Cx, diff --git a/crates/topcoat-router/src/response.rs b/crates/topcoat-router/src/response.rs index f69a78b56..0a124f307 100644 --- a/crates/topcoat-router/src/response.rs +++ b/crates/topcoat-router/src/response.rs @@ -1,16 +1,25 @@ -use std::{borrow::Cow, convert::Infallible}; +use std::{ + borrow::Cow, + convert::Infallible, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; use bytes::{Bytes, BytesMut}; +use futures_util::stream::FuturesUnordered; use http::{ Extensions, HeaderMap, StatusCode, header::{CONTENT_TYPE, HeaderName, HeaderValue}, response::Parts, }; +use http_body::Frame; +use http_body_util::StreamBody; use topcoat_core::{ - context::Cx, + context::{Cx, CxHandle}, error::{Error, Result}, }; -use topcoat_view::View; +use topcoat_view::{DeferredTask, View}; use crate::{Body, BoxError, content::Html}; @@ -19,6 +28,63 @@ pub type Response = http::Response; const TEXT_PLAIN: HeaderValue = HeaderValue::from_static("text/plain; charset=utf-8"); const APPLICATION_OCTET_STREAM: HeaderValue = HeaderValue::from_static("application/octet-stream"); +type PendingDeferred = Pin> + Send + 'static>>; + +struct DeferredResponseStream { + initial: Option, + cx: CxHandle, + pending: FuturesUnordered, +} + +impl DeferredResponseStream { + fn new(html: String, cx: CxHandle, deferred: Vec) -> Self { + let mut stream = Self { + initial: Some(Bytes::from(html)), + cx, + pending: FuturesUnordered::new(), + }; + stream.extend(deferred); + stream + } + + fn extend(&mut self, deferred: Vec) { + for task in deferred { + let cx = self.cx.clone(); + let id = task.id(); + self.pending + .push(Box::pin(async move { Ok((id, task.resolve(cx).await?)) })); + } + } +} + +impl futures_core::Stream for DeferredResponseStream { + type Item = Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(initial) = self.initial.take() { + return Poll::Ready(Some(Ok(Frame::data(initial)))); + } + + match futures_core::Stream::poll_next(Pin::new(&mut self.pending), cx) { + Poll::Ready(Some(Ok((id, view)))) => { + let rendered = view.render_response(&self.cx); + self.extend(rendered.deferred); + Poll::Ready(Some(Ok(Frame::data(Bytes::from(deferred_patch( + id, + &rendered.html, + )))))) + } + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +fn deferred_patch(id: u64, html: &str) -> String { + format!(r#""#) +} + /// Converts a value into an HTTP [`Response`]. /// /// Route handlers return any type that implements this trait. @@ -236,7 +302,14 @@ impl IntoResponse for Parts { impl IntoResponse for View { fn into_response(self, cx: &Cx) -> Result { let rendered = self.render_response(cx); - let mut response = Html(rendered.html).into_response(cx)?; + let mut response = if rendered.deferred.is_empty() { + Html(rendered.html).into_response(cx)? + } else { + let stream = DeferredResponseStream::new(rendered.html, cx.handle(), rendered.deferred); + let mut response = Html(String::new()).into_response(cx)?; + *response.body_mut() = Body::new(StreamBody::new(stream)); + response + }; if let Some(status_code) = rendered.status_code { *response.status_mut() = status_code; } @@ -420,8 +493,20 @@ impl_into_response_tuples!( #[cfg(test)] mod tests { + use std::{ + future::{pending, poll_fn}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::Poll, + time::Duration, + }; + + use futures_util::StreamExt as _; use http_body_util::Full; - use topcoat_view::{HtmlContext, NodeViewParts, PartsWriter, ViewParts}; + use topcoat_core::context::request_context; + use topcoat_view::{HtmlContext, NodeViewParts, PartsWriter, ViewParts, defer}; use super::*; use crate::to_bytes; @@ -553,6 +638,14 @@ mod tests { View::new(parts) } + fn compose(views: impl IntoIterator) -> View { + let mut parts = ViewParts::new(); + for view in views { + parts.push_view(view); + } + View::new(parts) + } + #[test] fn view_is_an_html_response() { let (parts, body) = run(view(|_cx, writer| { @@ -593,6 +686,140 @@ mod tests { assert_eq!(header(&parts, "content-type"), "application/xhtml+xml"); } + #[tokio::test] + async fn deferred_views_stream_template_patches_in_completion_order() { + let cx = Cx::default(); + let completed = Arc::new(Mutex::new(Vec::new())); + let slow_completed = Arc::clone(&completed); + let slow = defer( + view(|_cx, writer| { + writer.push_str("slow loading"); + }), + move |_cx| async move { + tokio::time::sleep(Duration::from_millis(20)).await; + slow_completed.lock().unwrap().push("slow"); + Ok(view(|_cx, writer| { + writer.push_str("slow ready"); + })) + }, + ); + let fast_completed = Arc::clone(&completed); + let fast = defer( + view(|_cx, writer| { + writer.push_str("fast loading"); + }), + move |_cx| async move { + fast_completed.lock().unwrap().push("fast"); + Ok(view(|_cx, writer| { + writer.push_str("fast ready"); + })) + }, + ); + let response = compose([slow, fast]).into_response(&cx).unwrap(); + let mut body = response.into_body().into_data_stream(); + + let shell = body.next().await.unwrap().unwrap(); + let shell = String::from_utf8(shell.to_vec()).unwrap(); + assert!(shell.contains("slow loading")); + assert!(shell.contains("fast loading")); + + let first = body.next().await.unwrap().unwrap(); + let first = String::from_utf8(first.to_vec()).unwrap(); + let second = body.next().await.unwrap().unwrap(); + let second = String::from_utf8(second.to_vec()).unwrap(); + assert!(first.contains("fast ready")); + assert!(second.contains("slow ready")); + assert!(first.starts_with("