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 nanohref from './lib/href.js'
import ComponentCache from './lib/cache.js'
import lazy, { PENDING } from './lib/lazy.js'
import { documentReady, scrollToAnchor } from './lib/dom.js'
import { ok, equal, notEqual } from './lib/assert.js'
export { lazy }
const HISTORY_OBJECT = {}
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
@@ -54,6 +57,7 @@ export class Choo {
this._loaded = false
this._stores = [ondomtitlechange]
this._tree = null
this._pendingTree = false
// state
const _state = {
@@ -159,14 +163,38 @@ export class Choo {
initStore(self.state)
})
this._tree = this._prerender(this.state)
ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
let tree = this._prerender(this.state)
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 () {
const renderTiming = nanotiming('choo.render')
const newTree = self._prerender(self.state)
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 <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.')
@@ -247,16 +275,73 @@ export class Choo {
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
ok(state.prefetch.length === 0, 'choo.toString: stores requested prefetch data — render this route with toStream() instead')
const html = this._prerender(state)
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)
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) {
let location, queryString
if (locationOverride) {