From 22c5045c251241f0e1110726e315864837095411 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 8 Sep 2026 18:43:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20toStream()=20and=20lazy()=20route?= =?UTF-8?q?s=20=E2=80=94=20the=20answer=20to=20choojs/choo#653?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd --- packages/core/index.js | 89 ++++++++++++++++++++++- packages/core/lib/lazy.js | 53 ++++++++++++++ packages/core/test/lazy-browser.test.js | 82 +++++++++++++++++++++ packages/core/test/stream.test.js | 96 +++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 packages/core/lib/lazy.js create mode 100644 packages/core/test/lazy-browser.test.js create mode 100644 packages/core/test/stream.test.js diff --git a/packages/core/index.js b/packages/core/index.js index a850e05..f855a4f 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -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) { diff --git a/packages/core/lib/lazy.js b/packages/core/lib/lazy.js new file mode 100644 index 0000000..43fedf9 --- /dev/null +++ b/packages/core/lib/lazy.js @@ -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 +} diff --git a/packages/core/test/lazy-browser.test.js b/packages/core/test/lazy-browser.test.js new file mode 100644 index 0000000..605c6e6 --- /dev/null +++ b/packages/core/test/lazy-browser.test.js @@ -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`

loading…

` + )) + + const tree = app.start() + assert.strictEqual(tree.textContent, 'loading…', 'loading view rendered while in flight') + + resolveLoader({ default: (state) => html`

arrived

` }) + 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`

real

` }) + 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`
cached ${state.count}
` + 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() +}) diff --git a/packages/core/test/stream.test.js b/packages/core/test/stream.test.js new file mode 100644 index 0000000..fc7dd80 --- /dev/null +++ b/packages/core/test/stream.test.js @@ -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`

hi

`) + 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`
`) + 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`

hello ${state.user}

`) + + const chunks = await readAll(app.toStream('/')) + assert.strictEqual(chunks.join(''), '

hello bendik

', '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`
`) + assert.throws(() => app.toString('/'), /toStream/) +}) + +test('async template holes stream: shell first, slow content later', async () => { + const app = choo() + app.route('/', (state) => html`

shell

${wait(15, html`
slow
`)}`) + + 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, '

shell

', '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, '
slow
') +}) + +test('toStream awaits lazy route views; toString refuses them', async () => { + const makeApp = () => { + const app = choo() + app.route('/', lazy(() => wait(10, { + default: (state) => html`
lazy view
` + }))) + return app + } + + assert.throws(() => makeApp().toString('/'), /toStream/) + + const chunks = await readAll(makeApp().toStream('/')) + assert.strictEqual(chunks.join(''), '
lazy view
') +}) + +test('stream errors propagate through the stream, not as sync throws', async () => { + const app = choo() + app.route('/', () => html`
${Promise.reject(new Error('boom'))}
`) + await assert.rejects(async () => readAll(app.toStream('/')), /boom/) +})