2026-09-08 15:59:26 +02:00
// Ported from choo 7.1.0 index.js (MIT) — https://github.com/choojs/choo
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
2026-09-08 19:55:20 +02:00
import morph from '@uhhm/buuh-html/morph'
import hydrate from '@uhhm/buuh-html/hydrate'
2026-09-08 15:59:26 +02:00
import nanotiming from './lib/timing.js'
import Nanorouter from './lib/router.js'
import Nanobus from './lib/bus.js'
import nanoquery from './lib/query.js'
import nanoraf from './lib/raf.js'
import nanohref from './lib/href.js'
import ComponentCache from './lib/cache.js'
2026-09-08 18:43:40 +02:00
import lazy , { PENDING } from './lib/lazy.js'
2026-09-08 15:59:26 +02:00
import { documentReady , scrollToAnchor } from './lib/dom.js'
import { ok , equal , notEqual } from './lib/assert.js'
2026-09-08 18:43:40 +02:00
export { lazy }
2026-09-08 15:59:26 +02:00
const HISTORY_OBJECT = {}
2026-09-08 16:50:34 +02:00
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
// but never throw on malformed input — keep the raw string instead.
function safeDecode ( location ) {
try {
return decodeURI ( location ). normalize ( 'NFC' )
} catch ( e ) {
return location
}
}
2026-09-08 15:59:26 +02:00
export class Choo {
constructor ( opts ) {
const timing = nanotiming ( 'choo.constructor' )
opts = opts || {}
equal ( typeof opts , 'object' , 'choo: opts should be type object' )
const self = this
// define events used by choo
this . _events = {
DOMCONTENTLOADED : 'DOMContentLoaded' ,
DOMTITLECHANGE : 'DOMTitleChange' ,
REPLACESTATE : 'replaceState' ,
PUSHSTATE : 'pushState' ,
NAVIGATE : 'navigate' ,
POPSTATE : 'popState' ,
RENDER : 'render'
}
// properties for internal use only
this . _historyEnabled = opts . history === undefined ? true : opts . history
this . _hrefEnabled = opts . href === undefined ? true : opts . href
this . _hashEnabled = opts . hash === undefined ? false : opts . hash
this . _hasWindow = typeof window !== 'undefined'
this . _cache = opts . cache
this . _loaded = false
this . _stores = [ ondomtitlechange ]
this . _tree = null
2026-09-08 18:43:40 +02:00
this . _pendingTree = false
2026-09-08 15:59:26 +02:00
// state
const _state = {
events : this . _events ,
components : {}
}
if ( this . _hasWindow ) {
this . state = window . initialState
? Object . assign ({}, window . initialState , _state )
: _state
delete window . initialState
} else {
this . state = _state
}
// properties that are part of the API
this . router = new Nanorouter ()
this . emitter = new Nanobus ( 'choo.emit' )
this . emit = this . emitter . emit . bind ( this . emitter )
// listen for title changes; available even when calling .toString()
if ( this . _hasWindow ) this . state . title = document . title
function ondomtitlechange ( state ) {
self . emitter . prependListener ( self . _events . DOMTITLECHANGE , function ( title ) {
equal ( typeof title , 'string' , 'events.DOMTitleChange: title should be type string' )
state . title = title
if ( self . _hasWindow ) document . title = title
})
}
timing ()
}
route ( route , handler ) {
const routeTiming = nanotiming ( "choo.route('" + route + "')" )
equal ( typeof route , 'string' , 'choo.route: route should be type string' )
equal ( typeof handler , 'function' , 'choo.handler: route should be type function' )
this . router . on ( route , handler )
routeTiming ()
}
use ( cb ) {
equal ( typeof cb , 'function' , 'choo.use: cb should be type function' )
const self = this
this . _stores . push ( function ( state ) {
let msg = 'choo.use'
msg = cb . storeName ? msg + '(' + cb . storeName + ')' : msg
const endTiming = nanotiming ( msg )
cb ( state , self . emitter , self )
endTiming ()
})
}
start () {
equal ( typeof window , 'object' , 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node' )
const startTiming = nanotiming ( 'choo.start' )
const self = this
if ( this . _historyEnabled ) {
this . emitter . prependListener ( this . _events . NAVIGATE , function () {
self . _matchRoute ( self . state )
if ( self . _loaded ) {
self . emitter . emit ( self . _events . RENDER )
setTimeout ( scrollToAnchor . bind ( null , window . location . hash ), 0 )
}
})
this . emitter . prependListener ( this . _events . POPSTATE , function () {
self . emitter . emit ( self . _events . NAVIGATE )
})
this . emitter . prependListener ( this . _events . PUSHSTATE , function ( href ) {
equal ( typeof href , 'string' , 'events.pushState: href should be type string' )
window . history . pushState ( HISTORY_OBJECT , null , href )
self . emitter . emit ( self . _events . NAVIGATE )
})
this . emitter . prependListener ( this . _events . REPLACESTATE , function ( href ) {
equal ( typeof href , 'string' , 'events.replaceState: href should be type string' )
window . history . replaceState ( HISTORY_OBJECT , null , href )
self . emitter . emit ( self . _events . NAVIGATE )
})
window . onpopstate = function () {
self . emitter . emit ( self . _events . POPSTATE )
}
if ( self . _hrefEnabled ) {
nanohref ( function ( location ) {
const href = location . href
const hash = location . hash
if ( href === window . location . href ) {
if ( ! self . _hashEnabled && hash ) scrollToAnchor ( hash )
return
}
self . emitter . emit ( self . _events . PUSHSTATE , href )
})
}
}
this . _setCache ( this . state )
this . _matchRoute ( this . state )
this . _stores . forEach ( function ( initStore ) {
initStore ( self . state )
})
2026-09-08 18:43:40 +02:00
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
2026-09-08 15:59:26 +02:00
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 )
2026-09-08 18:43:40 +02:00
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
}
2026-09-08 15:59:26 +02:00
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 () + '>.' )
const morphTiming = nanotiming ( 'choo.morph' )
morph ( self . _tree , newTree )
morphTiming ()
renderTiming ()
}))
documentReady ( function () {
self . emitter . emit ( self . _events . DOMCONTENTLOADED )
self . _loaded = true
})
startTiming ()
return this . _tree
}
mount ( selector ) {
const mountTiming = nanotiming ( "choo.mount('" + selector + "')" )
if ( typeof window !== 'object' ) {
ok ( typeof selector === 'string' , 'choo.mount: selector should be type String' )
this . selector = selector
mountTiming ()
return this
}
ok ( typeof selector === 'string' || typeof selector === 'object' , 'choo.mount: selector should be type String or HTMLElement' )
const self = this
documentReady ( function () {
const renderTiming = nanotiming ( 'choo.render' )
const newTree = self . start ()
if ( typeof selector === 'string' ) {
self . _tree = document . querySelector ( selector )
} else {
self . _tree = selector
}
ok ( self . _tree , 'choo.mount: could not query selector: ' + selector )
equal ( self . _tree . nodeName , newTree . nodeName , 'choo.mount: The target node <' +
self . _tree . nodeName . toLowerCase () + '> is not the same type as the new node <' +
newTree . nodeName . toLowerCase () + '>.' )
2026-09-08 17:49:32 +02:00
// First render adopts the existing (usually server-rendered) DOM:
// matching nodes are left in place, and any server/client markup
// disagreement is reported before the client render wins.
2026-09-08 15:59:26 +02:00
const morphTiming = nanotiming ( 'choo.morph' )
2026-09-08 17:49:32 +02:00
hydrate ( self . _tree , newTree , {
onMismatch : function ( diff ) {
console . warn (
'choo.mount: server and client markup differ at ' + diff . path +
' (' + diff . reason + '): server rendered ' + diff . server +
', client rendered ' + diff . client +
'. The client version wins; fix the view so both sides agree.'
)
}
})
2026-09-08 15:59:26 +02:00
morphTiming ()
renderTiming ()
})
mountTiming ()
}
toString ( location , state ) {
state = state || {}
state . components = state . components || {}
state . events = Object . assign ({}, state . events , this . _events )
notEqual ( typeof window , 'object' , 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser' )
equal ( typeof location , 'string' , 'choo.toString: location should be type string' )
equal ( typeof state , 'object' , 'choo.toString: state should be type object' )
this . _setCache ( state )
this . _matchRoute ( state , location )
this . emitter . removeAllListeners ()
2026-09-08 18:43:40 +02:00
state . prefetch = []
2026-09-08 15:59:26 +02:00
this . _stores . forEach ( function ( initStore ) {
initStore ( state )
})
2026-09-08 18:43:40 +02:00
ok ( state . prefetch . length === 0 , 'choo.toString: stores requested prefetch data — render this route with toStream() instead' )
2026-09-08 15:59:26 +02:00
const html = this . _prerender ( state )
ok ( html , 'choo.toString: no valid value returned for the route ' + location )
2026-09-08 18:43:40 +02:00
ok ( html !== PENDING , 'choo.toString: the route ' + location + ' loads its view lazily — render it with toStream() instead' )
2026-09-08 15:59:26 +02:00
ok ( ! Array . isArray ( html ), 'choo.toString: return value was an array for the route ' + location )
return typeof html . outerHTML === 'string' ? html . outerHTML : html . toString ()
}
2026-09-08 18:43:40 +02:00
// 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 )
}
}
})
}
2026-09-08 15:59:26 +02:00
_matchRoute ( state , locationOverride ) {
let location , queryString
if ( locationOverride ) {
location = locationOverride . replace ( /\?.+$/ , '' ). replace ( /\/$/ , '' )
if ( ! this . _hashEnabled ) location = location . replace ( /#.+$/ , '' )
queryString = locationOverride
} else {
location = window . location . pathname . replace ( /\/$/ , '' )
if ( this . _hashEnabled ) location += window . location . hash . replace ( /^#/ , '/' )
queryString = window . location . search
}
const matched = this . router . match ( location )
this . _handler = matched . cb
2026-09-08 16:50:34 +02:00
state . href = safeDecode ( location )
2026-09-08 15:59:26 +02:00
state . query = nanoquery ( queryString )
state . route = matched . route
state . params = matched . params
}
_prerender ( state ) {
const routeTiming = nanotiming ( "choo.prerender('" + state . route + "')" )
const res = this . _handler ( state , this . emit )
routeTiming ()
return res
}
_setCache ( state ) {
const cache = new ComponentCache ( state , this . emitter . emit . bind ( this . emitter ), this . _cache )
state . cache = renderComponent
function renderComponent ( Component , id , ... args ) {
equal ( typeof Component , 'function' , 'choo.state.cache: Component should be type function' )
return cache . render ( Component , id , ... args )
}
// When the state gets stringified, make sure `state.cache` isn't
// stringified too.
renderComponent . toJSON = function () {
return null
}
}
}
// Callable with or without `new`, matching choo v7's `choo()` usage.
export default function choo ( opts ) {
return new Choo ( opts )
}