Provides SSR suspense capabilities. Render a placeholder for a future, and stream the replacement elements.
Called columbo because Columbo always said, "And another thing..."
For the purposes of this library, the verb
suspendgenerally means "defer the rendering and sending of an async workload", in the context of rendering a web document.
The entrypoint for the library is the [new()] function, which returns a
[SuspenseContext] and a [SuspendedResponse]. The [SuspenseContext]
allows you to suspend() futures to be sent
down the stream when they are completed, wrapped with just enough HTML to be
interpolated into wherever the resulting [Suspense] struct was rendered
into the document as a placeholder. [SuspendedResponse] acts as a receiver
for these suspended results. When done rendering your document, pass it your
document and call into_stream() to get
seamless SSR streaming suspense.
So in summary:
- Use [
SuspenseContext] to callsuspend()and suspend futures. - Call
into_stream()to setup your response stream.
To spawn nested suspensions or access the [SuspenseContext] from within a
suspended future (e.g. to listen for cancellation), clone the context before
the async block and capture it by move.
Responses are streamed in completion order, not registration order, so the future that completes first will stream first.
By default, if [SuspendedResponse] or the type resulting from
into_stream() are dropped, the futures
that have been suspended will continue to run, but their results will be
inaccessible. If you would like for tasks to cancel instead, you can enable
auto_cancel in [ColumboOptions], or you can use
cancelled() or
is_cancelled() to exit early from within
the future.
use axum::{
body::Body,
response::{IntoResponse, Response},
};
async fn handler() -> impl IntoResponse {
// columbo entrypoint
let (ctx, resp) = columbo::new();
// suspend a future, providing a future and a placeholder
let suspense = ctx.suspend(
async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// the future can return any type that implements Into<Html>
"<p>Good things come to those who wait.</p>"
},
// placeholder replaced when result is streamed
"Loading...",
);
// directly interpolate the suspense into the document
let document = format!(
"<!DOCTYPE html><html><head></head><body><p>Aphorism \
incoming...</p>{suspense}</body></html>"
);
// produce a body stream with the document and suspended results
let stream = resp.into_stream(document);
let body = Body::from_stream(stream);
Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.header("Transfer-Encoding", "chunked")
.body(body)
.unwrap()
}Use [new_with_opts] to configure columbo behavior:
use std::any::Any;
use axum::{
body::Body,
response::{IntoResponse, Response},
};
use columbo::{ColumboOptions, Html};
fn panic_renderer(_panic_object: Box<dyn Any + Send>) -> Html {
Html::new("panic")
}
async fn handler() -> impl IntoResponse {
let (ctx, resp) = columbo::new_with_opts(ColumboOptions {
panic_renderer: Some(panic_renderer),
..Default::default()
});
// suspend a future, providing a future and a placeholder
let panicking_suspense = ctx.suspend(
async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
panic!("");
#[allow(unreachable_code)]
""
},
// placeholder replaced when result is streamed
"Loading...",
);
// directly interpolate the suspense into the document
let document = format!("{panicking_suspense}<p>at the disco</p>");
// produce a body stream with the document and suspended results
let stream = resp.into_stream(document);
let body = Body::from_stream(stream);
Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.header("Transfer-Encoding", "chunked")
.body(body)
.unwrap()
}Both integrations are enabled by default. Disable them individually via
default-features = false and re-enable selectively with features = ["axum"]
or features = ["maud"].
The axum feature implements IntoResponse for HtmlStream, the type
returned by into_stream(). This means you
can return the stream directly from an Axum handler — no manual Response
construction required:
use axum::response::IntoResponse;
async fn handler() -> impl IntoResponse {
let (ctx, resp) = columbo::new();
let suspense = ctx.suspend(
async move {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
"<p>Done.</p>"
},
"Loading...",
);
let document = format!("<html><body>{suspense}</body></html>");
resp.into_stream(document) // implements IntoResponse directly
}The response is automatically sent with Content-Type: text/html; charset=utf-8
and X-Content-Type-Options: nosniff.
The maud feature adds two conveniences:
-
maud::Markup→Html:maud::MarkupimplementsInto<Html>, sohtml! { ... }blocks can be passed directly as futures' return values or as placeholders. -
Suspenseimplementsmaud::Render: [Suspense] values can be interpolated directly intohtml! { ... }macros with(suspense).
use maud::{DOCTYPE, html};
use axum::response::IntoResponse;
async fn handler() -> impl IntoResponse {
let (ctx, resp) = columbo::new();
let suspense = ctx.suspend(
async move {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
html! { p { "Loaded!" } } // maud::Markup accepted directly
},
html! { "[loading]" }, // placeholder also accepts maud::Markup
);
let document = html! { // Suspense interpolates via maud::Render
(DOCTYPE)
html {
body { (suspense) }
}
};
resp.into_stream(document)
}Columbo injects a small script that watches for streamed <template> elements
and swaps them into their placeholders. You can customize how swaps are
performed by setting window.__columboConfig before the columbo script
runs:
<script>
window.__columboConfig = {
// placeholder: the <span> wrapping the original placeholder content
// nodes: array of resolved DOM nodes to swap in
swap: (placeholder, nodes) => {
placeholder.replaceWith(...nodes);
}
};
</script>The swap function receives the placeholder <span> element and an array of
resolved nodes. The default behavior is placeholder.replaceWith(...nodes).
This hook covers any use case without further API surface:
// Opt into View Transitions for smooth swaps
window.__columboConfig = {
swap: (placeholder, nodes) => {
if (document.startViewTransition) {
document.startViewTransition(() => placeholder.replaceWith(...nodes));
} else {
placeholder.replaceWith(...nodes);
}
}
};
// Use morphdom for minimal DOM diffing
window.__columboConfig = {
swap: (placeholder, nodes) => {
const wrapper = document.createElement('div');
nodes.forEach(n => wrapper.appendChild(n));
morphdom(placeholder, wrapper.innerHTML);
}
};
// Add a CSS class for a fade-in animation
window.__columboConfig = {
swap: (placeholder, nodes) => {
nodes.forEach(n => n.classList?.add('columbo-reveal'));
placeholder.replaceWith(...nodes);
}
};Internally, [SuspenseContext] holds a channel sender. When
suspend() is called, it launches a task which
runs the given future to completion. The result of this future (or a panic
message if it panicked) is wrapped in a <template> tag and sent as a
message to the channel. A single global <script> is injected once into
the initial document chunk and handles swapping each <template> into its
placeholder.
[SuspendedResponse] contains a receiver. It just sits around until you
call into_stream(), at which point the
receiver is turned into a stream whose elements are preceded by the
document you provide.