diff --git a/Cargo.lock b/Cargo.lock
index 2ae13e609..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"
@@ -3194,6 +3202,7 @@ name = "topcoat-view"
version = "0.5.0"
dependencies = [
"criterion",
+ "futures-util",
"http",
"itoa",
"memchr",
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#"{html} "#)
+}
+
/// 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("
(&cx).0;
+ Ok(view(|_cx, writer| {
+ writer.push_str(name);
+ }))
+ });
+ let response = greeting.into_response(&cx).unwrap();
+ drop(cx);
+
+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
+ assert!(String::from_utf8(body.to_vec()).unwrap().contains("Ada"));
+ }
+
+ struct DropFlag(Arc);
+
+ impl Drop for DropFlag {
+ fn drop(&mut self) {
+ self.0.store(true, Ordering::SeqCst);
+ }
+ }
+
+ #[tokio::test]
+ async fn dropping_the_response_cancels_deferred_views() {
+ let cx = Cx::default();
+ let dropped = Arc::new(AtomicBool::new(false));
+ let future_dropped = Arc::clone(&dropped);
+ let deferred = defer(View::empty(), move |_cx| async move {
+ let _drop_flag = DropFlag(future_dropped);
+ pending::<()>().await;
+ unreachable!()
+ });
+ let response = deferred.into_response(&cx).unwrap();
+ let mut body = response.into_body().into_data_stream();
+
+ body.next().await.unwrap().unwrap();
+ poll_fn(|cx| {
+ assert!(body.poll_next_unpin(cx).is_pending());
+ Poll::Ready(())
+ })
+ .await;
+ assert!(!dropped.load(Ordering::SeqCst));
+
+ drop(body);
+ assert!(dropped.load(Ordering::SeqCst));
+ }
+
// -- header arrays --
#[test]
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.rs b/crates/topcoat-view/grammar/src/view.rs
index 4ed6a3381..c6981756b 100644
--- a/crates/topcoat-view/grammar/src/view.rs
+++ b/crates/topcoat-view/grammar/src/view.rs
@@ -1,4 +1,5 @@
mod component;
+mod deferred;
mod document_type;
mod element;
mod element_name;
@@ -10,6 +11,7 @@ mod signal_declaration;
mod view_writer;
pub use component::*;
+pub use deferred::*;
pub use document_type::*;
pub use element::*;
pub use element_name::*;
diff --git a/crates/topcoat-view/grammar/src/view/component.rs b/crates/topcoat-view/grammar/src/view/component.rs
index ad06fa849..470720e73 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 ...)`.
@@ -70,8 +73,8 @@ impl topcoat_core_grammar::pretty::PrettyPrint for NamedArgValue {
}
}
-impl WriteView for Component {
- fn write(&self, writer: &mut ViewWriter) {
+impl Component {
+ pub(crate) fn render_future(&self) -> TokenStream {
let name = &self.path;
let setters = self.named_args.iter().map(|arg| {
@@ -84,29 +87,50 @@ 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,
+ 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
+ }
+ }
+ }
+ }
+}
+
+impl WriteView for Component {
+ fn write(&self, writer: &mut ViewWriter) {
+ writer.write_component(self.render_future());
}
}
diff --git a/crates/topcoat-view/grammar/src/view/deferred.rs b/crates/topcoat-view/grammar/src/view/deferred.rs
new file mode 100644
index 000000000..aeee47116
--- /dev/null
+++ b/crates/topcoat-view/grammar/src/view/deferred.rs
@@ -0,0 +1,95 @@
+use quote::quote_spanned;
+use syn::{
+ parse::{Parse, ParseStream},
+ spanned::Spanned,
+};
+use topcoat_core_grammar::{ParseOption, paths::topcoat_view};
+
+use crate::{
+ template::TemplateBlock,
+ view::{Component, ExprKind, Nodes, ViewWriter, WriteView},
+};
+
+mod kw {
+ syn::custom_keyword!(defer);
+}
+
+/// A deferred component and the placeholder rendered until it completes.
+pub struct Deferred {
+ pub defer_kw: kw::defer,
+ pub component: Component,
+ pub placeholder: TemplateBlock,
+}
+
+impl WriteView for Deferred {
+ fn write(&self, writer: &mut ViewWriter) {
+ let mut placeholder_writer = ViewWriter::new_nested();
+ self.placeholder.write(&mut placeholder_writer);
+ let placeholder = placeholder_writer.into_token_stream();
+ let component = self.component.render_future();
+ let deferred = quote_spanned! {self.defer_kw.span()=>
+ #topcoat_view::defer(#placeholder, move |__cx| async move {
+ let __cx = __cx.as_ref();
+ (#component).await
+ })
+ };
+ writer.write_expr(ExprKind::Node, deferred);
+ }
+}
+
+impl Parse for Deferred {
+ fn parse(input: ParseStream) -> syn::Result {
+ Ok(Self {
+ defer_kw: input.parse()?,
+ component: input.parse()?,
+ placeholder: input.parse()?,
+ })
+ }
+}
+
+impl ParseOption for Deferred {
+ fn peek(input: ParseStream) -> bool {
+ input.peek(kw::defer)
+ }
+}
+
+#[cfg(feature = "pretty")]
+impl topcoat_core_grammar::pretty::PrettyPrint for Deferred {
+ fn pretty_print(&self, printer: &mut topcoat_core_grammar::pretty::Printer<'_>) {
+ printer.move_cursor(self.defer_kw.span().start());
+ "defer".pretty_print(printer);
+ printer.move_cursor(self.defer_kw.span().end());
+ " ".pretty_print(printer);
+ self.component.pretty_print(printer);
+ " ".pretty_print(printer);
+ self.placeholder.pretty_print(printer);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parse(source: &str) -> Deferred {
+ syn::parse_str(source).unwrap()
+ }
+
+ #[test]
+ fn parses_component_and_placeholder() {
+ let deferred = parse(r#"defer activity(label: "recent") { "Loading"
}"#);
+
+ assert_eq!(deferred.component.path.segments[0].ident, "activity");
+ assert_eq!(deferred.placeholder.children.len(), 1);
+ }
+
+ #[test]
+ fn emits_a_deferred_view() {
+ let deferred = parse(r#"defer activity() { "Loading" }"#);
+ let mut writer = ViewWriter::new();
+ deferred.write(&mut writer);
+ let tokens = writer.into_token_stream().to_string();
+
+ assert!(tokens.contains("defer"), "{tokens}");
+ assert!(tokens.contains("activity"), "{tokens}");
+ }
+}
diff --git a/crates/topcoat-view/grammar/src/view/node.rs b/crates/topcoat-view/grammar/src/view/node.rs
index 1577f9c8e..5d347c807 100644
--- a/crates/topcoat-view/grammar/src/view/node.rs
+++ b/crates/topcoat-view/grammar/src/view/node.rs
@@ -10,7 +10,9 @@ use crate::{
MatchArmBody, RuntimeExpr, TemplateBlock, TemplateBreak, TemplateContinue, TemplateExpr,
TemplateForLoop, TemplateIf, TemplateLocal, TemplateMatch,
},
- view::{Component, DocumentType, Element, Nodes, SignalDeclaration, ViewWriter, WriteView},
+ view::{
+ Component, Deferred, DocumentType, Element, Nodes, SignalDeclaration, ViewWriter, WriteView,
+ },
};
/// A single child within a [`View`](super::View): the union of every construct
@@ -20,6 +22,7 @@ pub enum Node {
DocumentType(DocumentType),
Element(Box),
Component(Component),
+ Deferred(Deferred),
Expr(TemplateExpr),
RuntimeExpr(RuntimeExpr),
If(TemplateIf),
@@ -55,6 +58,7 @@ impl WriteView for Node {
Self::DocumentType(inner) => inner.write(writer),
Self::Element(inner) => inner.write(writer),
Self::Component(inner) => inner.write(writer),
+ Self::Deferred(inner) => inner.write(writer),
Self::Expr(inner) => inner.write(writer),
Self::RuntimeExpr(inner) => inner.write(writer),
Self::If(inner) => inner.write(writer),
@@ -77,6 +81,8 @@ impl Parse for Node {
Self::DocumentType(input.parse()?)
} else if Element::peek(input) {
Self::Element(input.parse()?)
+ } else if Deferred::peek(input) {
+ Self::Deferred(input.parse()?)
} else if TemplateExpr::peek(input) {
Self::Expr(input.parse()?)
} else if RuntimeExpr::peek(input) {
@@ -125,6 +131,7 @@ impl topcoat_core_grammar::pretty::PrettyPrint for Node {
Self::DocumentType(inner) => inner.pretty_print(printer),
Self::Element(inner) => inner.pretty_print(printer),
Self::Component(inner) => inner.pretty_print(printer),
+ Self::Deferred(inner) => inner.pretty_print(printer),
Self::Expr(inner) => inner.pretty_print(printer),
Self::RuntimeExpr(inner) => inner.pretty_print(printer),
Self::If(inner) => inner.pretty_print(printer),
@@ -160,6 +167,10 @@ mod tests {
assert!(matches!(parse(""), Node::DocumentType(_)));
assert!(matches!(parse(" "), Node::Element(_)));
assert!(matches!(parse("foo()"), Node::Component(_)));
+ assert!(matches!(
+ parse(r#"defer foo() { "loading" }"#),
+ Node::Deferred(_)
+ ));
assert!(matches!(parse("(value)"), Node::Expr(_)));
assert!(matches!(parse(r#"if a { "x" }"#), Node::If(_)));
assert!(matches!(parse(r"let a = 1;"), Node::Local(_)));
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/grammar/tests/fixtures/pretty/deferred_component.expected b/crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.expected
new file mode 100644
index 000000000..8bf0d0f35
--- /dev/null
+++ b/crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.expected
@@ -0,0 +1,7 @@
+view! {
+
+ defer recommendations(user_id: 42) {
+ "Loading recommendations"
+ }
+
+}
diff --git a/crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.input b/crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.input
new file mode 100644
index 000000000..b161ef9ef
--- /dev/null
+++ b/crates/topcoat-view/grammar/tests/fixtures/pretty/deferred_component.input
@@ -0,0 +1 @@
+view! { defer recommendations( user_id: 42) {"Loading recommendations"
} }
diff --git a/crates/topcoat-view/grammar/tests/pretty.rs b/crates/topcoat-view/grammar/tests/pretty.rs
index 7d3098bf1..d5f76cbae 100644
--- a/crates/topcoat-view/grammar/tests/pretty.rs
+++ b/crates/topcoat-view/grammar/tests/pretty.rs
@@ -117,6 +117,7 @@ fixture_test!(match_expr);
fixture_test!(component_empty);
fixture_test!(component_with_child);
fixture_test!(component_with_children);
+fixture_test!(deferred_component);
// -- Explicit context --------------------------------------------------------
diff --git a/crates/topcoat-view/macro/docs/view.md b/crates/topcoat-view/macro/docs/view.md
index 97b748f73..5ba1909dc 100644
--- a/crates/topcoat-view/macro/docs/view.md
+++ b/crates/topcoat-view/macro/docs/view.md
@@ -345,6 +345,35 @@ 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.
+
+# Deferred Components
+
+Prefix a component call with `defer` and follow it with the placeholder to send in the initial response:
+
+```rust
+# use topcoat::{Result, view::*};
+# #[component]
+# async fn activity() -> Result { view! { "Activity is ready."
} }
+# #[component]
+# async fn example() -> Result {
+view! {
+
+ "Activity"
+ defer activity() {
+ "Loading activity..."
+ }
+
+}
+# }
+```
+
+The macro returns an ordinary view containing the placeholder and deferred component work. Parent components and layouts compose that view normally. When the final view becomes an HTTP response, deferred components run concurrently and their completed views stream in completion order.
+
+The document must include the external `defer_script()` helper for streamed template patches to update the page. Use `View::defer` directly when the deferred work is not a component call.
+
# 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..f1243c926 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,209 @@ 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)
+
+ }
+}
+
+#[component]
+async fn deferred_content(label: &'static str) -> Result {
+ view! { (label) }
+}
+
+#[component]
+async fn deferred_panel(label: &'static str) -> Result {
+ view! {
+
+ defer deferred_content(label: label) {
+ "Loading"
+ }
+
+ }
+}
+
+#[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: label)
+ }
+ 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 c f ",
+ );
+}
+
+#[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),
+ "a b c ",
+ );
+}
+
+#[tokio::test]
+async fn inline_defer_records_work_on_an_ordinary_view() {
+ let cx = empty_cx();
+ let __cx = &cx;
+ let result: Result = view! {
+
+ defer deferred_content(label: "ready") {
+ "Loading"
+ }
+
+ };
+
+ let rendered = result.unwrap().render_response(__cx);
+ assert!(rendered.html.contains("Loading
"));
+ assert!(rendered.html.contains("data-topcoat-defer-start"));
+ assert_eq!(rendered.deferred.len(), 1);
+
+ let completed = rendered.deferred[0]
+ .clone()
+ .resolve(cx.handle())
+ .await
+ .unwrap();
+ assert_eq!(completed.render(__cx), "ready ");
+}
+
+#[tokio::test]
+async fn component_views_compose_without_an_include_helper() {
+ let cx = empty_cx();
+ let __cx = &cx;
+ let result: Result = view! {
+
+ deferred_panel(label: "activity")
+ deferred_panel(label: "recommendations")
+
+ };
+
+ let rendered = result.unwrap().render_response(__cx);
+ assert_eq!(rendered.deferred.len(), 2);
+
+ let activity = rendered.deferred[0]
+ .clone()
+ .resolve(cx.handle())
+ .await
+ .unwrap();
+ let recommendations = rendered.deferred[1]
+ .clone()
+ .resolve(cx.handle())
+ .await
+ .unwrap();
+
+ assert_eq!(activity.render(__cx), "activity ");
+ assert_eq!(
+ recommendations.render(__cx),
+ "recommendations "
+ );
+}
+
+#[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-a grandchild-a grandchild-b child-b parent-b ",
+ );
+}
+
#[component]
async fn no_args_component() -> Result {
view! { "static"
}
diff --git a/crates/topcoat-view/src/deferred.rs b/crates/topcoat-view/src/deferred.rs
new file mode 100644
index 000000000..914232f11
--- /dev/null
+++ b/crates/topcoat-view/src/deferred.rs
@@ -0,0 +1,110 @@
+use core::{future::Future, pin::Pin};
+use std::{
+ fmt,
+ sync::{
+ Arc, Mutex,
+ atomic::{AtomicU64, Ordering},
+ },
+};
+
+use topcoat_core::{
+ context::CxHandle,
+ error::{Error, Result},
+};
+
+use crate::{View, ViewPart};
+
+type DeferredFuture = Pin> + Send + 'static>>;
+type DeferredRender = Box DeferredFuture + Send + 'static>;
+
+static NEXT_ID: AtomicU64 = AtomicU64::new(0);
+
+struct DeferredState {
+ render: Option,
+}
+
+/// Work recorded while rendering a deferred view placeholder.
+#[doc(hidden)]
+#[derive(Clone)]
+pub struct DeferredTask {
+ id: u64,
+ state: Arc>,
+}
+
+impl DeferredTask {
+ #[inline]
+ #[must_use]
+ pub fn id(&self) -> u64 {
+ self.id
+ }
+
+ /// Resolves the deferred view against an owned request context.
+ ///
+ /// # Errors
+ ///
+ /// Returns the deferred renderer's error, or an error if the same task is
+ /// resolved more than once.
+ pub async fn resolve(self, cx: CxHandle) -> Result {
+ let render = self
+ .state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .render
+ .take()
+ .ok_or_else(|| std::io::Error::other("deferred view was already resolved"))?;
+ render(cx).await
+ }
+}
+
+impl fmt::Debug for DeferredTask {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("DeferredTask")
+ .field("id", &self.id)
+ .finish_non_exhaustive()
+ }
+}
+
+/// A placeholder and the work that will replace it in a streaming response.
+#[doc(hidden)]
+#[derive(Clone, Debug)]
+pub struct DeferredPart {
+ task: DeferredTask,
+ placeholder: Box,
+}
+
+impl DeferredPart {
+ #[inline]
+ #[must_use]
+ pub(crate) fn task(&self) -> &DeferredTask {
+ &self.task
+ }
+
+ #[inline]
+ #[must_use]
+ pub(crate) fn placeholder(&self) -> &ViewPart {
+ &self.placeholder
+ }
+}
+
+/// Creates a view that renders `placeholder` immediately and streams the
+/// completed view later when it is used as an HTTP response.
+///
+/// Views created this way compose like any other [`View`]. A parent view
+/// automatically carries deferred work from every nested child.
+#[must_use]
+pub fn defer(placeholder: View, render: F) -> View
+where
+ F: FnOnce(CxHandle) -> Fut + Send + 'static,
+ Fut: Future> + Send + 'static,
+{
+ let task = DeferredTask {
+ id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
+ state: Arc::new(Mutex::new(DeferredState {
+ render: Some(Box::new(move |cx| Box::pin(render(cx)))),
+ })),
+ };
+ View::from_part(ViewPart::Deferred(DeferredPart {
+ task,
+ placeholder: Box::new(placeholder.into_part()),
+ }))
+}
diff --git a/crates/topcoat-view/src/format.rs b/crates/topcoat-view/src/format.rs
index 4fde0a9db..d1d95cd19 100644
--- a/crates/topcoat-view/src/format.rs
+++ b/crates/topcoat-view/src/format.rs
@@ -1,6 +1,8 @@
#[cfg(feature = "http")]
use http::{HeaderMap, StatusCode};
+use crate::DeferredTask;
+
/// A plain string writer that render output accumulates into.
///
/// `Formatter` is escaping-agnostic: [`write_str`](Self::write_str) and
@@ -10,6 +12,7 @@ use http::{HeaderMap, StatusCode};
/// [`HtmlContext`](crate::HtmlContext) instead.
pub struct Formatter<'a> {
buf: &'a mut String,
+ deferred: Vec,
#[cfg(feature = "http")]
status_code: Option,
#[cfg(feature = "http")]
@@ -22,6 +25,7 @@ impl<'a> Formatter<'a> {
pub fn new(buf: &'a mut String) -> Self {
Self {
buf,
+ deferred: Vec::new(),
#[cfg(feature = "http")]
status_code: None,
#[cfg(feature = "http")]
@@ -29,6 +33,17 @@ impl<'a> Formatter<'a> {
}
}
+ #[inline]
+ pub(crate) fn record_deferred(&mut self, task: DeferredTask) {
+ if !self
+ .deferred
+ .iter()
+ .any(|existing| existing.id() == task.id())
+ {
+ self.deferred.push(task);
+ }
+ }
+
/// Writes a string verbatim.
#[inline]
pub fn write_str(&mut self, s: &str) {
@@ -70,8 +85,8 @@ impl<'a> Formatter<'a> {
/// Consumes the formatter, returning the recorded status code and
/// headers.
#[cfg(feature = "http")]
- pub(crate) fn into_recorded(self) -> (Option, HeaderMap) {
- (self.status_code, self.headers)
+ pub(crate) fn into_recorded(self) -> (Option, HeaderMap, Vec) {
+ (self.status_code, self.headers, self.deferred)
}
}
diff --git a/crates/topcoat-view/src/lib.rs b/crates/topcoat-view/src/lib.rs
index b940f8985..02a1a3839 100644
--- a/crates/topcoat-view/src/lib.rs
+++ b/crates/topcoat-view/src/lib.rs
@@ -3,6 +3,7 @@
mod attribute;
mod class;
mod component;
+mod deferred;
mod element;
mod escape;
mod format;
@@ -12,10 +13,12 @@ mod props;
pub mod svg;
mod unescaped;
mod view;
+mod view_tree;
pub use attribute::*;
pub use class::*;
pub use component::*;
+pub use deferred::*;
pub use element::*;
pub use escape::*;
pub use format::*;
@@ -28,8 +31,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..eeae42917 100644
--- a/crates/topcoat-view/src/view.rs
+++ b/crates/topcoat-view/src/view.rs
@@ -1,12 +1,15 @@
use core::{fmt, fmt::Write as _};
-use std::borrow::Cow;
+use std::{
+ borrow::Cow,
+ sync::{Arc, Mutex},
+};
#[cfg(feature = "http")]
use http::{HeaderMap, StatusCode};
use smallvec::SmallVec;
use topcoat_core::context::Cx;
-use crate::{Formatter, HtmlContext, HtmlWriter};
+use crate::{DeferredPart, Formatter, HtmlContext, HtmlWriter};
/// A self-contained piece of HTML content.
///
@@ -55,6 +58,21 @@ impl View {
}
}
+ /// Uses this view as a placeholder for work streamed after the response
+ /// shell.
+ ///
+ /// The returned view composes normally with other views. When it becomes
+ /// part of an HTTP response, `render` receives an owned request context
+ /// and its completed view replaces this placeholder.
+ #[must_use]
+ pub fn defer(self, render: F) -> Self
+ where
+ F: FnOnce(topcoat_core::context::CxHandle) -> Fut + Send + 'static,
+ Fut: Future> + Send + 'static,
+ {
+ crate::defer(self, render)
+ }
+
/// Renders the view into an HTML string.
#[cfg_attr(
feature = "http",
@@ -67,6 +85,7 @@ impl View {
///
/// Panics if a dynamic attribute key or element name in the view contains
/// a character that could break out of the identifier.
+ #[must_use]
#[track_caller]
pub fn render(&self, cx: &Cx) -> String {
let mut buf = String::with_capacity(self.part.size_hint());
@@ -96,11 +115,12 @@ impl View {
let mut html = String::with_capacity(self.part.size_hint());
let mut f = Formatter::new(&mut html);
self.part.render(cx, &mut f);
- let (status_code, headers) = f.into_recorded();
+ let (status_code, headers, deferred) = f.into_recorded();
RenderedResponse {
html,
status_code,
headers,
+ deferred,
}
}
@@ -109,6 +129,11 @@ impl View {
pub(crate) fn into_part(self) -> ViewPart {
self.part
}
+
+ #[inline]
+ pub(crate) fn from_part(part: ViewPart) -> Self {
+ Self { part }
+ }
}
/// The output of rendering a [`View`] for an HTTP response.
@@ -128,6 +153,9 @@ pub struct RenderedResponse {
/// Each name carries the values of the first render part that mentioned
/// it.
pub headers: HeaderMap,
+ /// Deferred work discovered while rendering the initial HTML.
+ #[doc(hidden)]
+ pub deferred: Vec,
}
/// A renderable value stored in a [`View`].
@@ -208,6 +236,12 @@ pub enum ViewPart {
inner: Box<[ViewPart]>,
size_hint: usize,
},
+ /// A placeholder backed by work resolved by a streaming response.
+ #[doc(hidden)]
+ Deferred(DeferredPart),
+ /// 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 +311,23 @@ impl ViewPart {
part.render(cx, f);
}
}
+ Self::Deferred(deferred) => {
+ let id = deferred.task().id();
+ write!(
+ f,
+ r#" "#
+ )
+ .unwrap();
+ deferred.placeholder().render(cx, f);
+ write!(f, r#" "#).unwrap();
+ f.record_deferred(deferred.task().clone());
+ }
+ 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 +373,12 @@ impl ViewPart {
_ => value.len() + value.len() / 8,
},
Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
+ Self::Deferred(deferred) => deferred.placeholder().size_hint() + 128,
+ 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))
+ }
+}
diff --git a/crates/topcoat/browser/defer.js b/crates/topcoat/browser/defer.js
new file mode 100644
index 000000000..530810895
--- /dev/null
+++ b/crates/topcoat/browser/defer.js
@@ -0,0 +1,46 @@
+const patchSelector = "template[data-topcoat-defer-patch]";
+
+function applyPatch(patch) {
+ const id = patch.dataset.topcoatDeferPatch;
+ const starts = document.querySelectorAll(
+ `template[data-topcoat-defer-start="${id}"]`,
+ );
+
+ for (const start of starts) {
+ let end = start.nextSibling;
+ while (
+ end &&
+ !(
+ end instanceof HTMLTemplateElement &&
+ end.dataset.topcoatDeferEnd === id
+ )
+ ) {
+ end = end.nextSibling;
+ }
+ if (!end) continue;
+
+ let node = start.nextSibling;
+ while (node !== end) {
+ const next = node.nextSibling;
+ node.remove();
+ node = next;
+ }
+ end.replaceWith(patch.content.cloneNode(true));
+ start.remove();
+ }
+
+ patch.remove();
+}
+
+function scan(node) {
+ if (!(node instanceof Element)) return;
+ if (node.matches(patchSelector)) applyPatch(node);
+ node.querySelectorAll(patchSelector).forEach(applyPatch);
+}
+
+document.querySelectorAll(patchSelector).forEach(applyPatch);
+new MutationObserver((records) => {
+ for (const record of records) {
+ record.addedNodes.forEach(scan);
+ }
+}).observe(document.documentElement, { childList: true, subtree: true });
diff --git a/crates/topcoat/docs/view.md b/crates/topcoat/docs/view.md
index 87f8fed22..f48e85f6b 100644
--- a/crates/topcoat/docs/view.md
+++ b/crates/topcoat/docs/view.md
@@ -5,8 +5,68 @@ This module provides Topcoat's HTML templating primitives:
- [`attributes!`]: builds a reusable runtime [`Attributes`] value from the same attribute syntax used inside [`view!`].
- [`class!`]: space-separated class lists from static and conditional entries.
+# Deferred views
+
+Any [`View`] can carry work that finishes after its response shell. Add [`defer_script`] to the document head, then use `defer component() { placeholder }` inside [`view!`]:
+
+```rust
+use topcoat::{
+ Result,
+ view::{component, defer_script, view},
+};
+
+#[component]
+async fn activity() -> Result {
+ view! { "Activity is ready."
}
+}
+
+#[component]
+async fn dashboard() -> Result {
+ view! {
+
+ defer_script()
+
+ defer activity() {
+ "Loading activity..."
+ }
+
+
+ }
+}
+```
+
+The placeholder is part of the first response chunk. Deferred components run concurrently after the response body starts, and each completed view is streamed in completion order. The browser helper is a bundled external script. It runs while the document is parsing so each streamed fragment is applied as it arrives. Streamed chunks contain inert, annotated `` elements rather than inline scripts.
+
+The asset bundle must be loaded on the router so [`defer_script`] has a URL. See the [`asset`](crate::asset) guide for setup.
+
+Use [`View::defer`] when the work is not a direct component call:
+
+```rust
+# use topcoat::{Result, view::{component, view}};
+# async fn load_activity() -> Result { view! { "ready"
} }
+# #[component]
+# async fn example() -> Result {
+let activity = view! {
+ "Loading activity..."
+}?
+.defer(|cx| async move {
+ let cx = cx.as_ref();
+ view! { cx => (load_activity().await?) }
+});
+
+view! { }
+# }
+```
+
+Deferred views compose as ordinary views. A component can return a view containing deferred work, and a parent can insert that component normally. The final response discovers the nested work automatically; there is no separate streaming view type or include helper.
+
+The response body owns each deferred future. Dropping the body drops pending work. Status codes and headers come from the initial shell because deferred views complete after response headers may be sent. Calling [`View::render`] directly renders the placeholder markers but does not run deferred work; return the view through the router to stream it.
+
[`view!`]: macro.view.html
[`component`]: attr.component.html
[`attributes!`]: macro.attributes.html
[`Attributes`]: struct.Attributes.html
[`class!`]: macro.class.html
+[`defer_script`]: fn.defer_script.html
+[`View`]: struct.View.html
+[`View::defer`]: struct.View.html#method.defer
diff --git a/crates/topcoat/src/view.rs b/crates/topcoat/src/view.rs
index ba6bf4621..92c1bcf6b 100644
--- a/crates/topcoat/src/view.rs
+++ b/crates/topcoat/src/view.rs
@@ -1,4 +1,57 @@
#![doc = include_str!("../docs/view.md")]
+#[cfg(feature = "asset")]
+use topcoat_asset::{Asset, asset};
pub use topcoat_view::*;
pub use topcoat_view_macro::*;
+
+#[cfg(feature = "asset")]
+const DEFER_SCRIPT: Asset = asset!("browser/defer.js", rename: "topcoat-defer");
+
+/// Renders the external browser helper that applies streamed deferred views.
+///
+/// Place this in the document head. It is intentionally parser-blocking so it
+/// can observe deferred fragments while the rest of the response streams.
+#[cfg(feature = "asset")]
+#[topcoat::view::component]
+pub async fn defer_script() -> topcoat::Result {
+ topcoat::view::view! { }
+}
+
+#[cfg(all(test, feature = "asset"))]
+mod tests {
+ use topcoat_asset::{AssetConfig, Manifest};
+ use topcoat_core::context::CxTestBuilder;
+ use topcoat_view::Component;
+
+ use super::*;
+
+ #[tokio::test]
+ async fn defer_script_runs_while_the_document_is_parsing() {
+ let manifest = Manifest::parse(&format!(
+ r#"
+version = 1
+
+[[assets]]
+id = {}
+file = "topcoat-defer.js"
+hash = "0"
+content_type = "text/javascript"
+"#,
+ DEFER_SCRIPT.id().as_u64()
+ ))
+ .unwrap();
+ let cx = CxTestBuilder::new()
+ .app_context(AssetConfig::hosted_at("https://example.com", manifest))
+ .build();
+ let props = defer_script::props_builder().build();
+ #[allow(clippy::default_constructed_unit_structs)]
+ let view = Component::render(defer_script::default(), &cx, props)
+ .await
+ .unwrap();
+ let html = view.render(&cx);
+
+ assert!(html.starts_with("