feat(core): toStream() and lazy() routes — the answer to choojs/choo#653

toStream(location, state) renders to a web-standard ReadableStream of
UTF-8 bytes: pass it to new Response() on web servers or
Readable.fromWeb().pipe(res) on Node. It awaits store prefetch promises
(state.prefetch, the pattern the #653 thread wanted standardized) and
lazy route views before rendering, then flushes template output
progressively through async holes.

lazy(loader, loadingView?) wraps a dynamic import as a route handler:
loads once, caches forever, renders the loading view (or holds the
current tree via a placeholder) while in flight, emits render on arrival.
toString() refuses lazy routes and prefetching stores with pointers to
toStream() — both halves of the async-route story ship together, unlike
the original PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-08 18:43:40 +02:00
co-authored by Claude Fable 5
parent 9cf9e51ac9
commit 22c5045c25
4 changed files with 318 additions and 2 deletions
+87 -2
View File
@@ -10,9 +10,12 @@ import nanoquery from './lib/query.js'
import nanoraf from './lib/raf.js' import nanoraf from './lib/raf.js'
import nanohref from './lib/href.js' import nanohref from './lib/href.js'
import ComponentCache from './lib/cache.js' import ComponentCache from './lib/cache.js'
import lazy, { PENDING } from './lib/lazy.js'
import { documentReady, scrollToAnchor } from './lib/dom.js' import { documentReady, scrollToAnchor } from './lib/dom.js'
import { ok, equal, notEqual } from './lib/assert.js' import { ok, equal, notEqual } from './lib/assert.js'
export { lazy }
const HISTORY_OBJECT = {} const HISTORY_OBJECT = {}
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'), // state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
@@ -54,6 +57,7 @@ export class Choo {
this._loaded = false this._loaded = false
this._stores = [ondomtitlechange] this._stores = [ondomtitlechange]
this._tree = null this._tree = null
this._pendingTree = false
// state // state
const _state = { const _state = {
@@ -159,14 +163,38 @@ export class Choo {
initStore(self.state) initStore(self.state)
}) })
this._tree = this._prerender(this.state) let tree = this._prerender(this.state)
ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href) ok(tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
if (tree === PENDING) {
// lazy route still loading and no loading view: hold the spot with
// a placeholder; the first real render replaces it wholesale
tree = document.createElement('div')
tree.setAttribute('data-choo-pending', '')
this._pendingTree = true
}
this._tree = tree
this.emitter.prependListener(self._events.RENDER, nanoraf(function () { this.emitter.prependListener(self._events.RENDER, nanoraf(function () {
const renderTiming = nanotiming('choo.render') const renderTiming = nanotiming('choo.render')
const newTree = self._prerender(self.state) const newTree = self._prerender(self.state)
ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href) ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href)
if (newTree === PENDING) {
// lazy route still loading: keep whatever is on screen
renderTiming()
return
}
if (self._pendingTree) {
// placeholder → first real view: replace, don't morph
const old = self._tree
self._tree = newTree
if (old.parentNode) old.parentNode.replaceChild(newTree, old)
self._pendingTree = false
renderTiming()
return
}
equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' + equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' + self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.') newTree.nodeName.toLowerCase() + '>.')
@@ -247,16 +275,73 @@ export class Choo {
this._setCache(state) this._setCache(state)
this._matchRoute(state, location) this._matchRoute(state, location)
this.emitter.removeAllListeners() this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) { this._stores.forEach(function (initStore) {
initStore(state) initStore(state)
}) })
ok(state.prefetch.length === 0, 'choo.toString: stores requested prefetch data — render this route with toStream() instead')
const html = this._prerender(state) const html = this._prerender(state)
ok(html, 'choo.toString: no valid value returned for the route ' + location) ok(html, 'choo.toString: no valid value returned for the route ' + location)
ok(html !== PENDING, 'choo.toString: the route ' + location + ' loads its view lazily — render it with toStream() instead')
ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location) ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString() return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
} }
// Streaming server render on web-standard streams: returns a
// ReadableStream of UTF-8 bytes. Compared to toString() it can wait —
// for store prefetch promises (pushed into state.prefetch during store
// init) and for lazy route views — and it flushes template output
// progressively: everything before an async hole is sent immediately,
// the rest follows as each promise resolves, in document order.
// Runtime-portable by construction: pass it to `new Response(stream)`
// on web-standard servers, or `Readable.fromWeb(stream).pipe(res)` on
// Node's http.
toStream (location, state) {
state = state || {}
state.components = state.components || {}
state.events = Object.assign({}, state.events, this._events)
notEqual(typeof window, 'object', 'choo.toStream: window was found. .toStream() must be called in Node, use .start() or .mount() if running in the browser')
equal(typeof location, 'string', 'choo.toStream: location should be type string')
const self = this
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
const prefetch = state.prefetch
const encoder = new TextEncoder()
return new ReadableStream({
async start (controller) {
try {
if (prefetch.length) await Promise.all(prefetch)
if (self._handler.__lazy) await self._handler.load()
const html = self._prerender(state)
ok(html, 'choo.toStream: no valid value returned for the route ' + location)
ok(!Array.isArray(html), 'choo.toStream: return value was an array for the route ' + location)
if (typeof html[Symbol.asyncIterator] === 'function') {
for await (const chunk of html) {
controller.enqueue(encoder.encode(chunk))
}
} else {
const str = typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
controller.enqueue(encoder.encode(str))
}
controller.close()
} catch (err) {
controller.error(err)
}
}
})
}
_matchRoute (state, locationOverride) { _matchRoute (state, locationOverride) {
let location, queryString let location, queryString
if (locationOverride) { if (locationOverride) {
+53
View File
@@ -0,0 +1,53 @@
// Lazy (async) routes — the v8 answer to choojs/choo#653.
//
// A lazy route handler loads its view on first match via a native dynamic
// import (or any promise-returning loader) and caches it forever after.
// In the browser, while the view is loading, the handler renders the
// optional loading view — or the PENDING sentinel, which tells choo to
// keep whatever is already on screen. When the module arrives the wrapper
// emits a render and the real view morphs in. On the server, toStream()
// awaits load() before rendering; toString() refuses lazy routes loudly
// (the missing server story is what stalled #653 — here both halves ship
// together).
import { equal } from './assert.js'
// Sentinel: "keep the current tree, a view is on its way"
export const PENDING = { __chooPending: true }
export default function lazy (loader, loading) {
equal(typeof loader, 'function', 'choo.lazy: loader should be type function')
let view = null
let promise = null
function load () {
if (!promise) {
promise = Promise.resolve(loader()).then(function (mod) {
view = (mod && mod.default) || mod
equal(typeof view, 'function', 'choo.lazy: loader should resolve to a view function (module default export)')
return view
})
}
return promise
}
function lazyView (state, emit) {
if (view) return view(state, emit)
load().then(function () {
emit(state.events.RENDER)
}, function (err) {
// surface load failures instead of hanging on the loading view
emit('error', err)
throw err
})
if (loading) return loading(state, emit)
return PENDING
}
lazyView.__lazy = true
lazyView.load = load
return lazyView
}
+82
View File
@@ -0,0 +1,82 @@
// Lazy routes in the browser (happy-dom): loading view while the module
// is in flight, morph to the real view on arrival, PENDING keeps the
// current tree during route changes, view cached after first load.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let choo, lazy, html
before(async () => {
const win = new Window({ url: 'http://localhost/' })
globalThis.window = win
globalThis.document = win.document
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
;({ default: choo, lazy } = await import('@choojs/core'))
;({ default: html } = await import('@choojs/html/browser'))
})
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
test('lazy route with a loading view: loading first, real view after', async () => {
let resolveLoader
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
const app = choo()
app.route('/', lazy(
() => loaderDone,
() => html`<div><p>loading…</p></div>`
))
const tree = app.start()
assert.strictEqual(tree.textContent, 'loading…', 'loading view rendered while in flight')
resolveLoader({ default: (state) => html`<div><p>arrived</p></div>` })
await tick()
assert.strictEqual(tree.textContent, 'arrived', 'real view morphed in over the loading view')
})
test('lazy route without a loading view: placeholder, then wholesale replace', async () => {
let resolveLoader
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
const app = choo()
app.route('/', lazy(() => loaderDone))
const tree = app.start()
assert.ok(tree.hasAttribute('data-choo-pending'), 'placeholder holds the spot')
document.body.appendChild(tree)
resolveLoader({ default: (state) => html`<main><h1>real</h1></main>` })
await tick()
assert.strictEqual(document.querySelector('main h1').textContent, 'real', 'real view replaced the placeholder in the DOM')
assert.strictEqual(document.querySelector('[data-choo-pending]'), null, 'placeholder gone')
document.querySelector('main').remove()
})
test('loaded lazy views render synchronously ever after', async () => {
const view = (state) => html`<div>cached ${state.count}</div>`
const wrapped = lazy(() => Promise.resolve({ default: view }))
const app = choo()
app.use((state, emitter) => {
state.count = 0
emitter.on('bump', () => { state.count++; emitter.emit('render') })
})
app.route('/', wrapped)
// no loading view: the placeholder must live in the DOM to be replaced
const holder = document.createElement('section')
document.body.appendChild(holder)
holder.appendChild(app.start())
await tick() // let the loader resolve + re-render
assert.strictEqual(holder.textContent, 'cached 0')
app.emit('bump')
await tick()
assert.strictEqual(holder.textContent, 'cached 1', 'subsequent renders are sync through the cache')
holder.remove()
})
+96
View File
@@ -0,0 +1,96 @@
// choo.toStream(): web-standard streaming SSR with prefetch and lazy routes.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@choojs/html'
import choo, { lazy } from '@choojs/core'
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
async function readAll (stream) {
const decoder = new TextDecoder()
const chunks = []
for await (const chunk of stream) chunks.push(decoder.decode(chunk, { stream: true }))
return chunks
}
test('toStream matches toString for a plain sync app', async () => {
const makeApp = () => {
const app = choo()
app.route('/', (state) => html`<div><h1>hi</h1></div>`)
return app
}
const chunks = await readAll(makeApp().toStream('/'))
assert.strictEqual(chunks.join(''), makeApp().toString('/'))
})
test('returns a web ReadableStream', () => {
const app = choo()
app.route('/', () => html`<div></div>`)
assert.ok(app.toStream('/') instanceof ReadableStream)
})
test('stores can defer rendering with state.prefetch promises', async () => {
const app = choo()
app.use((state) => {
state.user = null
state.prefetch.push(wait(10, null).then(() => { state.user = 'bendik' }))
})
app.route('/', (state) => html`<p>hello ${state.user}</p>`)
const chunks = await readAll(app.toStream('/'))
assert.strictEqual(chunks.join(''), '<p>hello bendik</p>', 'render waited for prefetch')
})
test('toString refuses prefetching stores with guidance', () => {
const app = choo()
app.use((state) => { state.prefetch.push(Promise.resolve()) })
app.route('/', () => html`<div></div>`)
assert.throws(() => app.toString('/'), /toStream/)
})
test('async template holes stream: shell first, slow content later', async () => {
const app = choo()
app.route('/', (state) => html`<body><h1>shell</h1>${wait(15, html`<section>slow</section>`)}</body>`)
const stream = app.toStream('/')
const reader = stream.getReader()
const decoder = new TextDecoder()
const started = performance.now()
const first = decoder.decode((await reader.read()).value)
const firstAt = performance.now() - started
assert.strictEqual(first, '<body><h1>shell</h1>', 'shell flushed immediately')
assert.ok(firstAt < 10, `shell arrived before the slow hole resolved (${firstAt.toFixed(1)}ms)`)
let rest = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
rest += decoder.decode(value, { stream: true })
}
assert.strictEqual(rest, '<section>slow</section></body>')
})
test('toStream awaits lazy route views; toString refuses them', async () => {
const makeApp = () => {
const app = choo()
app.route('/', lazy(() => wait(10, {
default: (state) => html`<main>lazy view</main>`
})))
return app
}
assert.throws(() => makeApp().toString('/'), /toStream/)
const chunks = await readAll(makeApp().toStream('/'))
assert.strictEqual(chunks.join(''), '<main>lazy view</main>')
})
test('stream errors propagate through the stream, not as sync throws', async () => {
const app = choo()
app.route('/', () => html`<div>${Promise.reject(new Error('boom'))}</div>`)
await assert.rejects(async () => readAll(app.toStream('/')), /boom/)
})