Ajo

Server rendering

Render components to HTML with ajo/html and continue on the client.

The same components that run in the browser can render to HTML on a server. ajo/html turns a tree into markup, the browser shows it before any script runs, and render from ajo then takes over the existing DOM instead of rebuilding it.

Render to a string

render(children) from ajo/html returns the markup as a string. Server code often imports it under another name, to keep it apart from the browser’s render.

server/journal.tsx
import { render as toHTML } from 'ajo/html'
import { Journal } from '../src/journal'

const notes = await loadNotes()           // your data access

const html = toHTML(<Journal notes={notes} />) // a string of HTML, ready to send

html(children, emit) produces the same markup in pieces, calling emit with each chunk in document order, so you can write to a response as the tree is walked.

server/page.tsx
import { html } from 'ajo/html'
import { Journal, type Note } from '../src/journal'

export function write(res: { write(chunk: string): void }, notes: Note[]) {
  res.write('<!DOCTYPE html><html lang="en"><body><div id="root">')
  html(<Journal notes={notes} />, chunk => res.write(chunk))
  res.write('</div><script type="module" src="/main.js"></script></body></html>')
}

Both functions are synchronous. Plain elements and text are emitted as they are reached; a stateful component’s markup is collected and emitted in one piece when it finishes, so an error it catches can replace its partial output. Nothing waits for promises: load data first and pass it in as args.

Stateful components on the server

A stateful component renders exactly once during server rendering:

  1. Its setup code runs, then the loop up to the first yield.
  2. The yielded tree is rendered inside the host, with the host’s tag, attrs and attr: values.
  3. The generator is finished with return(), so its finally blocks run, and then its signal aborts.

On the server, this is not an element. It only implements the host protocol: it can be iterated for args, it has a signal, and it reads and writes context. next() and return() do nothing, and throw() rethrows its argument. Refs are never called and event handlers are never attached.

Error boundaries work the same way. If rendering the yielded children throws, the error is thrown into the component at its yield; its partial markup is discarded and the fallback its catch block yields is rendered instead. Errors it does not catch go on to the stateful components around it. Apart from finally blocks, code after the first yield never runs on the server.

Guard browser-only code

Setup code runs on the server too. Anything that needs the DOM or other browser-only APIs, such as listeners on document, observers or localStorage, belongs behind a guard, and so does work that only matters in the browser, such as timers. Two checks cover it: typeof document != 'undefined' tells you whether there is a DOM at all, and this.nodeType == 1 tells you whether the host is a real element.

src/draft.tsx
import type { Stateful } from 'ajo'

export const Draft: Stateful = function* () {

  let text = ''

  if (typeof document != 'undefined' && this.nodeType == 1) {
    text = localStorage.getItem('draft') ?? ''
    const id = setInterval(() => localStorage.setItem('draft', text), 5000)
    this.signal.addEventListener('abort', () => clearInterval(id))
  }

  while (true) yield (
    <textarea set:value={text} set:oninput={e => text = (e.target as HTMLTextAreaElement).value} />
  )
}

On the server this renders an empty text area; in the browser it restores the saved draft once it takes over. The clove rules apply the same guard to shared logic.

What the HTML contains

Server output follows the same rules as the browser where it can, and is careful with anything that could turn data into markup.

InputOutput
Text and attribute valuesEscaped: & < > " ' become numeric character references.
An attribute set to trueThe bare attribute name, as in disabled.
false, null or undefinedThe attribute is left out.
key, memo, skip, ref and every set: nameNot rendered. Event handlers, set:value and set:innerHTML leave no trace in the HTML.
An attribute name with whitespace, quotes, /, >, = or control charactersDropped.
A tag name that does not match ^[A-Za-z][\w:.-]*$Replaced with defaults.tag from ajo/html.
Void elements, such as input, img and brNo closing tag; children are ignored.
A plain object that was not created by JSXRendered as escaped text, such as [object Object], never as an element.
Children of an element with skipRendered. In the browser, Ajo then leaves them as they are.

Since set: names are not rendered, a form field whose value must appear in the server HTML needs the attribute too: <input value={title} set:value={title} />. For a text area, render the text as its child. Content inserted with set:innerHTML is not in the server HTML either.

The void elements are area, base, br, col, command, embed, hr, img, input, keygen, link, meta, param, source, track and wbr.

Take over in the browser

In the browser, call render from ajo on the element that holds the server HTML, with the same component and the same data. Ajo reconciles the existing DOM rather than replacing it:

  • Elements with the same tag are reused, and attributes the JSX doesn’t mention are removed.
  • Text is brought up to date with the JSX.
  • Each stateful component takes over its server-rendered host and starts its generator, so setup code now runs in the browser and attaches its listeners.
  • Keyed lists adopt the server’s list items in order; keys are not part of the HTML.
  • Refs are called and set: properties, including event handlers, are assigned.
src/main.tsx
import { render } from 'ajo'
import { Journal } from './journal'

const notes = JSON.parse(document.getElementById('notes')!.textContent!)

render(<Journal notes={notes} />, document.getElementById('root')!)

The data has to match what the server used, so send it with the page, for example as JSON in a script element. Where the two trees differ, Ajo does not report a mismatch; it corrects the DOM in place, as on any other render. To take over only part of a container, pass the optional child and ref arguments described in Installation.

With Ajo Kit

Ajo Kit does all of this for every route. It runs the route’s loaders on the server, renders the page and its layouts with ajo/html, writes the route data into the document, and renders the same route in the browser over the server HTML. Routing and Loaders show how routes and their data are defined.

Source of truth: README.md in cristianfalcone/ajo