test+docs: full-app browser integration test and the isomorphic counter example
The integration test runs the counter through the real browser path in happy-dom: start(), store events, raf-batched morph, DOM click handlers, emit coalescing. examples/counter is the Phase 2 exit criterion: one app module mounted zero-build in a browser via import map and string-rendered by node examples/counter/render.js. 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
c696e00d4f
commit
870aa79668
+22
-5
@@ -19,17 +19,34 @@ everything new lives under `packages/`.
|
|||||||
- `packages/html` — `@choojs/html`: the rendering package.
|
- `packages/html` — `@choojs/html`: the rendering package.
|
||||||
- `server.js` — server-side tagged template (← nanohtml 1.10.0 server,
|
- `server.js` — server-side tagged template (← nanohtml 1.10.0 server,
|
||||||
transform branches removed; pure runtime)
|
transform branches removed; pure runtime)
|
||||||
|
- `browser.js` — runtime-only cached template tag: each template literal
|
||||||
|
is parsed once (WeakMap keyed on its strings array) into a <template>
|
||||||
|
plus hole instructions; renders clone and fill. Values never pass
|
||||||
|
through innerHTML. Document-level roots (<body> etc.) parse via
|
||||||
|
DOMParser since <template> drops them. Known limits: no dynamic tag
|
||||||
|
names, no holes in raw-text elements, SVG fragments need their <svg>
|
||||||
|
root, and (unlike v7) whitespace is preserved as authored — which is
|
||||||
|
what makes server and browser output byte-identical.
|
||||||
- `morph.js` — ← nanomorph 5.4.3, consolidated to one module
|
- `morph.js` — ← nanomorph 5.4.3, consolidated to one module
|
||||||
- `browser.js` — **Phase 2**: runtime-only cached template tag (WeakMap
|
- `raw.js` — mark pre-encoded strings (works with both renderers)
|
||||||
keyed on the template strings array, clone-based instantiation,
|
- `packages/component` — `@choojs/component`: ← nanocomponent 6.6.0 +
|
||||||
hydration that adopts server DOM). Throws until implemented.
|
on-load 3.4.1 as ES classes; the future island/hydration boundary.
|
||||||
- `raw.js` — mark pre-encoded strings
|
- `examples/counter` — the isomorphic proof: one app module, mounted
|
||||||
|
zero-build in the browser via import map (`index.html`), string-rendered
|
||||||
|
in Node (`render.js`).
|
||||||
|
|
||||||
## Status vs the modernization plan
|
## Status vs the modernization plan
|
||||||
|
|
||||||
- [x] Phase 1: monorepo scaffold, ESM ports, choo v7 node test suite green
|
- [x] Phase 1: monorepo scaffold, ESM ports, choo v7 node test suite green
|
||||||
on `node --test` (Node ≥ 24), CI on GitHub Actions
|
on `node --test` (Node ≥ 24), CI on GitHub Actions
|
||||||
- [ ] Phase 2: browser renderer rewrite + hydration, `@choojs/component`
|
- [x] URL normalization fix: WHATWG URL parsing, single decode with raw
|
||||||
|
fallback (no more URIError on '%'), NFC matching, per-segment
|
||||||
|
wildcard decode, decoded state.href
|
||||||
|
- [x] Phase 2 (core): browser renderer rewrite, `@choojs/component`,
|
||||||
|
zero-build counter example, full-app integration test in happy-dom
|
||||||
|
- [ ] Phase 2 (remaining): adoption-style hydration with mismatch
|
||||||
|
warnings, Playwright suite in a real browser, benchmarks vs
|
||||||
|
nanohtml v1 / µhtml
|
||||||
- [ ] Phase 3: v8 wiring (`toStream`, devtools, codemod)
|
- [ ] Phase 3: v8 wiring (`toStream`, devtools, codemod)
|
||||||
- [ ] Phase 4: bankai v10 (Vite 8/Rolldown shell, SSR middleware,
|
- [ ] Phase 4: bankai v10 (Vite 8/Rolldown shell, SSR middleware,
|
||||||
103 Early Hints, service worker, precompression)
|
103 Early Hints, service worker, precompression)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// The classic choo counter. This one module is the whole app, and both
|
||||||
|
// sides consume it: the browser mounts it (import map resolves
|
||||||
|
// @choojs/html to the DOM renderer), Node stringifies it (same specifier
|
||||||
|
// resolves to the string renderer). That's the isomorphic contract.
|
||||||
|
|
||||||
|
import choo from '@choojs/core'
|
||||||
|
import html from '@choojs/html'
|
||||||
|
|
||||||
|
export default function createApp () {
|
||||||
|
const app = choo()
|
||||||
|
app.use(countStore)
|
||||||
|
app.route('/', mainView)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainView (state, emit) {
|
||||||
|
return html`
|
||||||
|
<body>
|
||||||
|
<h1>count is ${state.count}</h1>
|
||||||
|
<button onclick=${onclick}>Increment</button>
|
||||||
|
</body>
|
||||||
|
`
|
||||||
|
|
||||||
|
function onclick () {
|
||||||
|
emit('increment', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countStore (state, emitter) {
|
||||||
|
state.count = state.count || 0
|
||||||
|
emitter.on('increment', function (count) {
|
||||||
|
state.count += count
|
||||||
|
emitter.emit('render')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>choo v8 counter — zero build</title>
|
||||||
|
<!--
|
||||||
|
No bundler, no compiler, no node_modules in the browser: an import map
|
||||||
|
and native ES modules. Serve this directory from the repo root, e.g.
|
||||||
|
npx serve .
|
||||||
|
then open /examples/counter/
|
||||||
|
-->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@choojs/core": "../../packages/core/index.js",
|
||||||
|
"@choojs/core/timing": "../../packages/core/lib/timing.js",
|
||||||
|
"@choojs/html": "../../packages/html/browser.js",
|
||||||
|
"@choojs/html/raw": "../../packages/html/raw.js",
|
||||||
|
"@choojs/html/morph": "../../packages/html/morph.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module">
|
||||||
|
import createApp from './app.js'
|
||||||
|
createApp().mount('body')
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Server-side render of the exact same app module the browser mounts:
|
||||||
|
// node examples/counter/render.js
|
||||||
|
import createApp from './app.js'
|
||||||
|
|
||||||
|
process.stdout.write(createApp().toString('/') + '\n')
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// End-to-end in happy-dom: the counter app running the real browser path —
|
||||||
|
// start(), store, emit, nanoraf-batched re-render, nanomorph in place.
|
||||||
|
|
||||||
|
import { test, before } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { Window } from 'happy-dom'
|
||||||
|
|
||||||
|
let choo, 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 } = await import('@choojs/core'))
|
||||||
|
;({ default: html } = await import('@choojs/html/browser'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
test('a full app renders, emits, and morphs in the browser', async () => {
|
||||||
|
const app = choo()
|
||||||
|
|
||||||
|
app.use((state, emitter) => {
|
||||||
|
state.count = 0
|
||||||
|
emitter.on('increment', (n) => {
|
||||||
|
state.count += n
|
||||||
|
emitter.emit('render')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.route('/', (state, emit) => {
|
||||||
|
return html`
|
||||||
|
<body>
|
||||||
|
<h1>count is ${state.count}</h1>
|
||||||
|
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
||||||
|
</body>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
const tree = app.start()
|
||||||
|
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 0')
|
||||||
|
|
||||||
|
// state event → render event → raf-batched morph of the same tree
|
||||||
|
app.emit('increment', 1)
|
||||||
|
await tick()
|
||||||
|
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 1', 'emit re-rendered in place')
|
||||||
|
|
||||||
|
// and through an actual DOM click on the interpolated handler
|
||||||
|
tree.querySelector('button').click()
|
||||||
|
await tick()
|
||||||
|
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 2', 'click handler emitted')
|
||||||
|
|
||||||
|
// raf batching: two synchronous emits, one render
|
||||||
|
app.emit('increment', 1)
|
||||||
|
app.emit('increment', 1)
|
||||||
|
await tick()
|
||||||
|
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 4', 'batched emits coalesced')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user