Each template literal parses once, keyed by its strings array in a WeakMap: static parts become a <template> with hole markers, renders clone and fill. Interpolated values never pass through innerHTML (child values become text nodes or adopted DOM; attributes go through setAttribute). Event handlers set as properties so nanomorph copies them. Document-level roots (<body>/<head>/<html>) parse via DOMParser because <template> drops them. Replaces the v7 browserify transform entirely — production speed is now a runtime property. Tests run in happy-dom (new devDependency), including a check that server and browser renderers serialize identical markup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
149 lines
4.9 KiB
JavaScript
149 lines
4.9 KiB
JavaScript
// Browser renderer, exercised in happy-dom. Globals are installed before
|
|
// the dynamic import because browser.js touches `document` at parse time.
|
|
|
|
import { test, before } from 'node:test'
|
|
import assert from 'node:assert'
|
|
import { Window } from 'happy-dom'
|
|
|
|
let html, raw, morph
|
|
|
|
before(async () => {
|
|
const win = new Window()
|
|
globalThis.window = win
|
|
globalThis.document = win.document
|
|
;({ default: html } = await import('../browser.js'))
|
|
;({ default: raw } = await import('../raw.js'))
|
|
;({ default: morph } = await import('../morph.js'))
|
|
})
|
|
|
|
test('renders an element with text', () => {
|
|
const el = html`<p>hello</p>`
|
|
assert.strictEqual(el.tagName, 'P')
|
|
assert.strictEqual(el.textContent, 'hello')
|
|
})
|
|
|
|
test('interpolated strings become text, never markup', () => {
|
|
const el = html`<p>${'<script>alert(1)</script>'}</p>`
|
|
assert.strictEqual(el.querySelector('script'), null)
|
|
assert.strictEqual(el.textContent, '<script>alert(1)</script>')
|
|
})
|
|
|
|
test('the same call site reuses its parsed template', () => {
|
|
const view = (x) => html`<span>${x}</span>`
|
|
const a = view('a')
|
|
const b = view('b')
|
|
assert.notStrictEqual(a, b, 'each render is a fresh node')
|
|
assert.strictEqual(a.textContent, 'a')
|
|
assert.strictEqual(b.textContent, 'b')
|
|
})
|
|
|
|
test('nested templates embed as nodes', () => {
|
|
const inner = html`<em>hi</em>`
|
|
const el = html`<p>${inner}</p>`
|
|
assert.strictEqual(el.querySelector('em'), inner)
|
|
})
|
|
|
|
test('arrays render as siblings', () => {
|
|
const items = ['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)
|
|
const el = html`<ul>${items}</ul>`
|
|
assert.strictEqual(el.querySelectorAll('li').length, 3)
|
|
assert.strictEqual(el.textContent, 'abc')
|
|
})
|
|
|
|
test('null and undefined children render as nothing', () => {
|
|
const el = html`<p>${null}${undefined}ok</p>`
|
|
assert.strictEqual(el.textContent, 'ok')
|
|
})
|
|
|
|
test('numbers and booleans stringify', () => {
|
|
const el = html`<p>${42} ${false}</p>`
|
|
assert.strictEqual(el.textContent, '42 false')
|
|
})
|
|
|
|
test('raw() parses vouched HTML', () => {
|
|
const el = html`<p>${raw('<em>hi</em>')}</p>`
|
|
assert.ok(el.querySelector('em'))
|
|
})
|
|
|
|
test('unquoted attribute holes', () => {
|
|
const el = html`<div class=${'a b'}></div>`
|
|
assert.strictEqual(el.getAttribute('class'), 'a b')
|
|
})
|
|
|
|
test('quoted attribute holes compose with static text', () => {
|
|
const el = html`<div class="btn ${'primary'} lg"></div>`
|
|
assert.strictEqual(el.getAttribute('class'), 'btn primary lg')
|
|
})
|
|
|
|
test('boolean attributes toggle', () => {
|
|
const on = html`<input disabled=${true} />`
|
|
const off = html`<input disabled=${false} />`
|
|
assert.ok(on.hasAttribute('disabled'))
|
|
assert.strictEqual(on.disabled, true)
|
|
assert.ok(!off.hasAttribute('disabled'))
|
|
})
|
|
|
|
test('event handlers attach as properties', () => {
|
|
let clicks = 0
|
|
const el = html`<button onclick=${() => clicks++}>go</button>`
|
|
assert.strictEqual(el.getAttribute('onclick'), null, 'no handler serialized to markup')
|
|
el.click()
|
|
assert.strictEqual(clicks, 1)
|
|
})
|
|
|
|
test('spread objects set attributes and handlers', () => {
|
|
let clicks = 0
|
|
const el = html`<div ${{ class: 'x', hidden: true, onclick: () => clicks++ }}></div>`
|
|
assert.strictEqual(el.getAttribute('class'), 'x')
|
|
assert.ok(el.hasAttribute('hidden'))
|
|
el.click()
|
|
assert.strictEqual(clicks, 1)
|
|
})
|
|
|
|
test('value attribute also sets the property', () => {
|
|
const el = html`<input value=${'typed'} />`
|
|
assert.strictEqual(el.value, 'typed')
|
|
})
|
|
|
|
test('multiple roots return a fragment', () => {
|
|
const frag = html`<li>a</li><li>b</li>`
|
|
assert.strictEqual(frag.nodeType, 11)
|
|
assert.strictEqual(frag.childNodes.length, 2)
|
|
})
|
|
|
|
test('leading/trailing whitespace still unwraps a single root', () => {
|
|
const el = html`
|
|
<p>hi</p>
|
|
`
|
|
assert.strictEqual(el.tagName, 'P')
|
|
})
|
|
|
|
test('document-level roots survive parsing, attributes included', () => {
|
|
// <template> silently drops <body>/<head>/<html>; we must not
|
|
const el = html`<body class=${'app'}><h1>${'hi'}</h1></body>`
|
|
assert.strictEqual(el.tagName, 'BODY')
|
|
assert.strictEqual(el.getAttribute('class'), 'app')
|
|
assert.strictEqual(el.querySelector('h1').textContent, 'hi')
|
|
})
|
|
|
|
test('re-render + morph updates in place and keeps handlers', () => {
|
|
let clicks = 0
|
|
const view = (n) => html`<div><button onclick=${() => clicks++}>count ${n}</button></div>`
|
|
const tree = view(0)
|
|
document.body.appendChild(tree)
|
|
|
|
morph(tree, view(1))
|
|
assert.strictEqual(tree.textContent, 'count 1', 'text updated')
|
|
tree.querySelector('button').click()
|
|
assert.strictEqual(clicks, 1, 'handler from new tree is live')
|
|
document.body.removeChild(tree)
|
|
})
|
|
|
|
test('server and browser renderers agree on simple markup', async () => {
|
|
// spot check: identical template, identical serialization
|
|
const { default: serverHtml } = await import('../server.js')
|
|
const browserEl = html`<p class=${'x'}>hi ${'there'}</p>`
|
|
const serverStr = serverHtml`<p class=${'x'}>hi ${'there'}</p>`
|
|
assert.strictEqual(browserEl.outerHTML, serverStr.toString())
|
|
})
|