github tokio-rs/topcoat v0.7.0

4 hours ago

Topcoat 0.7

This release brings streaming server-side rendering to Topcoat. A page no longer has to finish rendering before the browser sees any of it: the parts that are ready go out right away, and the slow parts stream in when they finish, over the same response and without any client-side fetching. The new live! and emit! macros are the general form of this, and the suspense and error_boundary components are the two most common shapes prepackaged.

To make streaming possible, views became lazy. A view! expression is now a value that renders later, much like an async move block, and View is now a trait. This changes the signature of every page, layout, and component, so there is a migration section below.

Streaming SSR

A page normally renders in full before the browser sees any of it, so a single slow database query or upstream request delays everything, even the parts that are ready. With streaming, the page sends what it has and fills in the rest as it becomes available. The browser needs no client library for this; the response carries everything the swap requires.

Live regions with live! and emit!

live! marks a region of the page whose content can still change while the response streams. Its body is ordinary async Rust, which can be formatted via topcoat fmt. Inside the body, emit! renders markup into the region, and every emission replaces the previous one in the browser.

#[page("/")]
async fn quote() -> Result<impl View> {
    Ok(view! {
        <h1>"Quote of the day"</h1>
        (live! {
            emit! { <p>"Loading..."</p> }?;
            let quote = fetch_quote().await;
            emit! { <blockquote>(quote)</blockquote> }
        })
    })
}

The heading and the loading message reach the browser immediately. While fetch_quote runs, the rest of the page streams as usual, and once the quote is ready it replaces the loading message in place.

The page waits for a region's first emission and renders it with the rest of the document, so start the body with something that is ready right away, like the loading message above.

emit! accepts everything view! does: elements, text, interpolated expressions, control flow, and components. Between emissions the body is plain async Rust, so it can await work, loop, and branch. Because each emission replaces the previous one, a live region can narrate a long-running task as it happens:

#[page("/progress")]
async fn progress() -> Result<impl View> {
    Ok(view! {
        <h1>"Progress"</h1>
        (live! {
            for percent in 0..100 {
                emit! { <p>"Working... " (percent) "%"</p> }?;
                run_step().await;
            }
            emit! { <p>"Done!"</p> }
        })
    })
}

emit! evaluates to a Result carrying an EmitToken, and the body of live! has to returns one. This is a compile-time reminder that a region has to emit at least once so it never leaves a hole in the page. Ending the body with an emission satisfies it naturally; when the control flow does not end with one, return Ok(EmitToken) yourself.

Handling errors in a region

An emission fails when the markup inside it fails to render, for example when a component it calls returns an error. The failure comes back as the Err value of emit! instead of ending the stream, and the body decides what happens next: propagate it with ?, or handle it and emit a fallback in its place.

#[page("/weather")]
async fn weather() -> Result<impl View> {
    Ok(view! {
        <h1>"Weather"</h1>
        (live! {
            emit! { <p>"Loading..."</p> }?;
            match emit! { forecast() } {
                Err(error) => emit! {
                    <p>"The forecast is unavailable: " (error.to_string())</p>
                },
                emitted => emitted,
            }
        })
    })
}

suspense and error_boundary

The two most common shapes come prepackaged as components, so most pages never need the macros directly.

suspense is a live region that shows a fallback until its child content is ready:

Ok(view! {
    suspense(
        fallback: view! { <p>"Loading..."</p> },
        daily_quote()
    )
})

error_boundary renders its child content and swaps in a fallback built from the error when any part of it fails. Returning the error from the fallback rethrows it, so a boundary can pick the errors it handles and let the rest bubble up:

Ok(view! {
    error_boundary(
        fallback: |error| Ok(view! {
            <p>"The stats are unavailable: " (error.to_string())</p>
        }),
        stats()
    )
})

Both compose: wrapping a suspense in an error_boundary streams a widget in behind a fallback and turns its failure into a message in place, while the rest of the page is unaffected.

Error boundaries also replace the old way of catching a page's error in a layout. A layout used to receive the slot as a Result and match on it; it has to wrap the slot in an error boundary (or live! region):

#[layout("/")]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
    Ok(view! {
        <html>
            <body>
                error_boundary(
                    fallback: |error| {
                        if error.downcast_ref::<NotFoundError>().is_none() {
                            // Any other error type is rethrown.
                            return Err(error);
                        }

                        Ok(view! {
                            (StatusCode::NOT_FOUND)
                            <h1>"Page not found"</h1>
                        })
                    },
                    (slot)
                )
            </body>
        </html>
    })
}

A region is a view

A live region is a view like any other. A component can return one directly, take one as child content, or interpolate it into a view! body:

#[component]
async fn daily_quote() -> Result<impl View> {
    Ok(live! {
        emit! { <p>"Loading..."</p> }?;
        let quote = fetch_quote().await;
        emit! { <blockquote>(quote)</blockquote> }
    })
}

#[page("/")]
async fn quote() -> Result<impl View> {
    Ok(view! {
        <h1>"Quote of the day"</h1>
        daily_quote()
    })
}

Several regions on one page stream independently, each replacing its own content as it becomes ready, and emitted markup can itself contain components and further live regions.

What streaming changes about the response

Once the first content of a page goes out, the response is committed. That has a few consequences worth knowing:

  • Status codes and headers declared in a view take effect only if they are part of the first content. Anything a region emits later cannot change them anymore.
  • A redirect raised before the response commits is a real HTTP redirect. A redirect raised inside a region after the page started streaming reaches the browser as a client-side navigation to the target instead. Redirect targets are now percent-encoded rather than panicking on characters a header cannot carry.
  • An error that escapes after the response committed can no longer turn the page into an error response. Wrap streamed content in an error_boundary, or handle the error in the region, to show something useful in its place.
  • Cookies are response headers, so they must be written before the response commits. Writing to the jar from streamed content panics; reading always works.

The new live and suspense examples in examples/ show all of this end to end.

Views are lazy

To make streaming SSR work well, we made Views lazy. This means that a view! block is not executed in place, but instead returns a closure to be executed later. the expressions and component calls inside of a view! that is never used are never executed. This change introduces a bunch of trade-offs, but we believe it is the best choice moving forward to more advanced features.

The new signature for every page, layout, component, and shard now has a return type of Result<impl View> and wraps its view in Ok:

#[component]
async fn hello(name: &str) -> Result<impl View> {
    Ok(view! { <h1>"Hello, " (name) "!"</h1> })
}

Views behave like async move blocks

A view value captures every variable the template mentions by moving it into the view, exactly like an async move block or a move closure, which is what the macro expands to:

let title = String::from("Hello");
let header = view! { <h1>(title)</h1> };

// `title` has moved into `header` and cannot be used here anymore.
Ok(view! {
    (header)
    <p>"Welcome!"</p>
})

When a value is needed both inside the view and after it, interpolate a clone instead.

A view that captures a reference borrows whatever it points at, so it cannot outlive that data. In practice this rarely gets in the way: component props and anything borrowed from the request context stay alive until the render is over, so they are safe to use in a view even when they are references, like a &str prop.

What laziness buys

  • Streaming. A view that is only described, not rendered, can be driven by the framework piece by piece, which is what live! and suspense need.
  • Nothing runs for content that is not shown. A layout now decides where and when (or if!) its slot renders. Child content a component never interpolates never runs either.
  • Errors flow through the tree. Because rendering happens inside the view tree, an error raised deep in a page bubbles up through it until a boundary catches it. There is no ? after view! anymore, and nothing to unwrap by hand.
  • Recursion without a special mode. Recursive components box their view with .boxed(), which replaces the old #[component(boxed)] attribute.

What laziness costs

  • Move semantics. Values a view mentions move into it. Anything used afterwards needs a clone, the same way a move closure does.
  • Trickier lifetimes. Views that borrow locals from a function without moving them into the view cannot be returned.
  • Error handling. Errors have to be caught with live! + emit! or via the new error_boundary. view!s no longer return a Result to match on.
  • Anonymous types. Every view! has its own type, so a function that returns a view from several return sites has to erase them with .boxed() to give them a common type. The same applies to one component in any recursive cycle.
  • Lifetimes show up in signatures. Child content is typed Child<'_> and a layout's slot Slot<'_>, since they borrow from the render they belong to. Give a child prop #[default] so the component can also be called without children.
  • No rendered output where the view is built. Code that needs the finished markup as a value, such as a route that returns a view inside a tuple with htmx or Datastar headers, resolves it first with .single().await?, which yields a ViewHandle. A layout can no longer inspect the slot's Result; use error_boundary instead.

Migrating from 0.6

The following changes touch most applications. Each is mechanical.

  • Return types: Result becomes Result<impl View>, and the body wraps its view in Ok(...). Drop the ? that used to follow view!. Import View from topcoat::view.
  • Layouts: slot: Result becomes slot: Slot<'_>, imported from topcoat::router, and (slot?) becomes (slot).
  • Child content: child: View becomes #[default] child: Child<'_>.
  • Recursive components: replace #[component(boxed)] with .boxed() on the returned view, imported through topcoat::view::ViewExt.
  • Catching errors in a layout: replace matching on the slot with an error_boundary around it. The fallback receives the error, can downcast it, and can rethrow it by returning it.
  • Pages that never render, such as one that always redirects, return Result<()>.
  • Routes that return a view inside a tuple resolve it with .single().await? and type it as ViewHandle, which is the name the old concrete View struct now carries.
  • Route handlers now convert their return value through AsyncIntoResponse, which every IntoResponse type implements, so existing routes keep working unchanged.
  • Cookies must be written before the response commits; move jar writes out of streamed content.

Other changes

Router

  • Href gained is_current, and routes and pages gained the same, to tell whether a link points at the page the current request is serving. A link without a query stays current while its page is filtered or paginated.
  • HrefTarget is implemented for &T where T: HrefTarget, so trait objects work as link targets.
  • Redirect targets are percent-encoded instead of panicking on characters a Location header cannot carry.

CLI

  • The dev server walks up from an occupied port to the first free one and reports the port it picked.
  • The dev script no longer reloads the page over an in-flight navigation when it reconnects.
  • TopcoatCli exposes a run method for embedding the CLI.
  • topcoat fmt no longer depends on prettyplease; the pretty printer covers all syn types itself.

Core and runtime

  • #[memoize] on an async function with a borrowed argument no longer fails the Send check inside handler futures.
  • Boolean procedure results are preserved. The generated JavaScript no longer exposes a then method that made the value a thenable and turned it into undefined.

UI

  • Topcoat UI switched its icon set from Feather to Lucide.
  • The neutral theme's primary and ring colors were adjusted.
  • The ui example was revised to avoid misleading showcases.

Toolchain

  • The workspace builds on Rust 1.98.

Looking ahead

Streaming is the first thing live! and emit! make possible, but a region's body is just async Rust that can keep emitting for as long as the response is open. We want to explore what that allows for more advanced use cases, such as server push, where the server keeps updating a region as things change. That work is for a future release; for now, live regions are about getting pages to the browser sooner.

Don't miss a new topcoat release

NewReleases is sending notifications on new releases.