Packages renamed to the @uhhm scope (@uhhm/buuh, @uhhm/buuh-html, @uhhm/buuh-component, @uhhm/buuh-devtools, @uhhm/buuh-migrate, @uhhm/bankai). Scoping is load-bearing twice over: npm routes registries per scope so @uhhm/* resolves against project.uhhm.no while everything else stays on npmjs, and it means this fork never squats upstream's names anywhere. The codemod now migrates choo v7 apps to the @uhhm names. README rewritten with the fork framing and full upstream credit; the choojs RFC moves to docs/upstream-rfc-draft.md, in the drawer for if this work ever goes home. API unchanged — choo() is still choo(). Also: Gitea Actions CI + release workflows (npm publish to the uhhm registry on tag push, CDN bundle uploaded as a generic package), npm run bundle producing dist-cdn/buuh.js (the whole framework as one minified ES module for import-map use), docs/publishing.md explaining what Gitea Packages is (a real npm registry) and is not (a CDN — serve the bundle from a static host with module-safe MIME instead), and onload.js constructing window.MutationObserver to match its own guard (surfaced by smoke-testing the bundle outside a full browser). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
94 lines
3.1 KiB
JavaScript
94 lines
3.1 KiB
JavaScript
// The isomorphic handshake: server-render a view to a string, parse it
|
|
// into the document the way a browser would, then mount() the same app —
|
|
// the server DOM must be adopted (same element references), handlers must
|
|
// come alive, and a tampered server render must produce a console warning.
|
|
|
|
import { test, before } from 'node:test'
|
|
import assert from 'node:assert'
|
|
import { Window } from 'happy-dom'
|
|
|
|
let choo, browserHtml, serverString
|
|
|
|
before(async () => {
|
|
// Server side first, before any window exists — exactly like production,
|
|
// and toString() enforces it.
|
|
;({ default: choo } = await import('@uhhm/buuh'))
|
|
const { default: serverHtml } = await import('@uhhm/buuh-html/server')
|
|
serverString = makeApp(serverHtml).toString('/')
|
|
|
|
const win = new Window({ url: 'http://localhost/' })
|
|
globalThis.window = win
|
|
globalThis.document = win.document
|
|
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
|
;({ default: browserHtml } = await import('@uhhm/buuh-html/browser'))
|
|
})
|
|
|
|
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
// One view definition, parameterized by renderer — mirrors how the import
|
|
// map serves browser.js to the browser and server.js to Node.
|
|
function makeApp (html) {
|
|
const app = choo()
|
|
app.use((state, emitter) => {
|
|
state.count = state.count || 0
|
|
emitter.on('increment', (n) => {
|
|
state.count += n
|
|
emitter.emit('render')
|
|
})
|
|
})
|
|
app.route('/', (state, emit) => html`
|
|
<div class="app">
|
|
<h1>count is ${state.count}</h1>
|
|
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
|
</div>
|
|
`)
|
|
return app
|
|
}
|
|
|
|
test('mount adopts server-rendered DOM and brings it alive', async () => {
|
|
document.body.innerHTML = serverString
|
|
const serverEl = document.querySelector('.app')
|
|
const serverH1 = serverEl.querySelector('h1')
|
|
|
|
const warnings = []
|
|
const warn = console.warn
|
|
console.warn = (msg) => warnings.push(msg)
|
|
|
|
const app = makeApp(browserHtml)
|
|
app.mount('.app')
|
|
await tick()
|
|
|
|
console.warn = warn
|
|
|
|
assert.strictEqual(document.querySelector('.app'), serverEl, 'server root adopted, not replaced')
|
|
assert.strictEqual(serverEl.querySelector('h1'), serverH1, 'server child adopted, not replaced')
|
|
assert.deepStrictEqual(warnings, [], 'identical markup produced no mismatch warnings')
|
|
|
|
serverEl.querySelector('button').click()
|
|
await tick()
|
|
assert.strictEqual(serverH1.textContent, 'count is 1', 'adopted DOM is live')
|
|
})
|
|
|
|
test('a tampered server render produces one mismatch warning, client wins', async () => {
|
|
document.body.innerHTML = serverString.replace('count is 0', 'count is 999')
|
|
|
|
const warnings = []
|
|
const warn = console.warn
|
|
console.warn = (msg) => warnings.push(msg)
|
|
|
|
const app = makeApp(browserHtml)
|
|
app.mount('.app')
|
|
await tick()
|
|
|
|
console.warn = warn
|
|
|
|
assert.strictEqual(warnings.length, 1, 'exactly one warning')
|
|
assert.match(warnings[0], /server and client markup differ/)
|
|
assert.match(warnings[0], /count is 999/)
|
|
assert.strictEqual(
|
|
document.querySelector('h1').textContent,
|
|
'count is 0',
|
|
'client render won'
|
|
)
|
|
})
|