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) {
|
||||
|
||||
Reference in New Issue
Block a user