The home of Jollytoad

JSX Streaming

TLDR; This is about using JSX as an async streaming template language in Deno, and has very little to do with React.

Introduction

I quite like JSX as templating language, it's almost identical to HTML, with a few caveats and slightly stricter semantics (more akin to XHTML) and simply makes use of JS/TS for all logic. So it's very familiar and saves having to learn new syntax, esp if you put aside all of the React specific peculiarities. (I remember the days of E4X)

Experiments

Following the release of Fresh I started to make use of Preact as my JSX runtime for server-side rendering, but I felt I didn't need the entire React-like feature set that came with it. I wanted to just make use of JSX as a template language, and ideally streaming the output. So I started experimenting using Hastscript as the runtime, but unfortunately it didn't stream either.

I started to think about how I could strip the runtime back to the bare essentials necessary for server-side rendering.

Synchronous Streaming

What if, the jsx function was a generator function, emitting an Iterable of strings, along the lines of...

(NOTE: This is very simplified, unsafe code, just to demonstrate the concept)

export function* jsx(type: any, props: any): Iterable<string> {

  if (typeof type === "function") {

    // Call the function component and yield it's strings
    yield* type(props);

  } else if (typeof type === "string") {

    // A html element
    // render and yield the opening tag
    const attrs = /* ... build attrs string ... */;
    yield `<${type} ${attrs}>`;

    // Yield all it's children
    yield* props.children;

    // Yield the closing tag
    yield `</${type}>`;

  } else if (type === null) {

    // A fragment, just yield all the children
    yield* props.children;

  }
}

Along with a serializing function to convert that into a ReadableStream which can be returned as the body of a Response.

This still required a two-stage request handling, similar to Fresh, where you have to do all your async work before starting to render. This can become tricky if you need to supply data to many components.

async function handler(req: Request): Promise<Response> {
  // Stage 1: fetch data asynchronously
  const name = await fetchMyName(req);

  // Stage 2: render
  const element = <MyName name={name} />;
  const stream = renderBody(element);
  return new Response(stream);
}

function MyName({ name }: { name: string }) {
  return <div>My name is: {name}</div>;
}

I've always wondered why components can't just be asynchronous, and it turns out, there is nothing preventing a JSX runtime handling async components.

Asynchronous Streaming

What if, as well as emitting an synchronous Iterable, the jsx function could emit an AsyncIterable of strings, this not only allows a JSX component to be an async function, returning a Promise, but also to be an async generator, returning a AsyncIterable too.

Now any component can perform whatever async task it needs to obtain it's data, including async streaming of data and elements.

And not a hook in sight.

function handler(req: Request): Response {
  // Single-stage: just render it
  const element = <MyName req={req} />;
  const stream = renderBody(element);
  return new Response(stream);
}

async function MyName({ req }: { req: Request }) {
  const name = await fetchMyName(req);

  return <div>My name is: {name}</div>;
}

NOTE: The handler doesn't even need to be async.

Async components allows the HTML to be streamed almost immediately, without having to wait for all of the first stage async stuff to complete. But, it also means that the stream will block as soon as it hits the first async component.

Deferred Streaming

What if we could skip over slow blocking components, deferring their stream until later, allowing faster and sync components to continue streaming their content?

We could drop a placeholder with a unique id into the stream, and later render the content once ready with a script to substitute it over the placeholder, and do this all within the same streamed response.

So given a top-level component like this...

function Page(req: Request) {
  return (
    <body>
      <h1>Hello</h1>

      <MyName req={req} />

      <p>This is not blocked</p>
    </body>
  );
}

A placeholder would be rendered something like this...

<body>
  <h1>Hello</h1>

  <span id="deferred_1"></span>

  <p>This is not blocked</p>
</body>

Then later in the same stream, once the component has resolved...

<template id="_deferred_1">
  <div>My name is: Mark</div>
</template>
<script>document.getElementById("deferred_1").outerHTML = document.getElementById("_deferred_1").innerHTML;</script>

NOTE: It doesn't matter that the template and script are rendered outside of the body or html element, the HTML5 parser in every browser is designed to handle this gracefully and move it into the body within the parsed DOM.

Examples

The JSX streaming module does all of this, and this site includes a couple of pages that demonstrate the async streaming and deferral of slow components (albeit they are somewhat exaggerated).

This async component fetches a question from The Trivia API, then renders the question and each option with a slight delay, to give the effect of them being revealed one at a time, without blocking the rendering of the rest of the page.

This page demonstrates the Delayed async component to delay the rendering of three blocks of text using a promise, and the Trickled component which is a async generator component, that introduces a delay before each child is rendered.

Take a look at the raw source of the page in the browser, and the DOM in the browser dev tools.

Even this blog makes use of an async Markdown component that fetches the raw markdown before converting to HTML. Unfortunately the parsing of markdown itself isn't streamed yet, but it's something I'd like to do in the future.

Deno Module

jsx_stream is available as a Deno module, you can find the source on GitHub.

NOTE: It's still fairly experimental, lacking docs, and hasn't been tested for many edge cases, but it does power this website.

Discussion

If you'd like to ask a question or discuss this blog further please use the GitHub discussion.