Skip to main content

Crate columbo

Crate columbo 

Source
Expand description

§columbo

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 suspend generally means “defer the rendering and sending of an async workload”, in the context of rendering a web document.

§Overview

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:

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.

§Cancel Safety

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.

§Axum Example

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()
}

§Integrations

Both integrations are enabled by default. Disable them individually via default-features = false and re-enable selectively with features = ["axum"] or features = ["maud"].

§Axum (feature = "axum")

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.

§Maud (feature = "maud")

The maud feature adds two conveniences:

  1. maud::MarkupHtml: maud::Markup implements Into<Html>, so html! { ... } blocks can be passed directly as futures’ return values or as placeholders.

  2. Suspense implements maud::Render: Suspense values can be interpolated directly into html! { ... } 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)
}

§Client-side Configuration

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);
  }
};

§Architecture

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.

Structs§

ColumboOptions
Options for configuring columbo suspense.
Html
Pre-escaped HTML content. Columbo’s canonical markup type.
SuspendedResponse
Contains suspended results. Can be turned into a byte stream with a prepended document.
Suspense
A suspended future. Can be interpolated into markup as the placeholder.
SuspenseContext
The context with which you can create suspense boundaries for futures.

Constants§

GLOBAL_SCRIPT_CONTENTS
The contents of the script that performs the on-the-fly replacements.

Functions§

new
Creates a new SuspenseContext and SuspendedResponse. The context is for suspending futures, and the response turns into an output stream.
new_with_opts
Creates a new SuspenseContext and SuspendedResponse, with the given ColumboOptions. The context is for suspending futures, and the response turns into an output stream.