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:
co-authored by
Claude Fable 5
parent
9cf9e51ac9
commit
22c5045c25
+87
-2
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user