import { html, keyed, type Displayable, type Renderable } from 'dhtml' import { attr, hydrate, invalidate, onMount, type Directive, type Root } from 'dhtml/client' import { renderToString } from 'dhtml/server' import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' let phase: 'server' | 'client' | null = null function setup(template: Displayable): { root: Root; el: HTMLDivElement } { const el = document.createElement('div') phase = 'server' el.innerHTML = renderToString(template) document.body.appendChild(el) phase = 'client' const root = hydrate(el, template) phase = null return { root, el } } // Basic Hydration Tests test('basic html hydrates correctly', () => { const { root, el } = setup(html`

Hello, world!

`) assert_eq(el.innerHTML, '

Hello, world!

') // Test that subsequent renders work root.render(html`

Updated!

`) assert_eq(el.innerHTML, '

Updated!

') }) test('dynamic content hydrates correctly', () => { const template = (n: number) => html`

Hello, ${n}!

` const { root, el } = setup(template(42)) assert_eq(el.innerHTML, '

Hello, 42!

') // Test dynamic updates root.render(template(84)) assert_eq(el.innerHTML, '

Hello, 84!

') }) test('nested templates hydrate correctly', () => { const { root, el } = setup(html`

${html`Inner content!`}

`) assert_eq(el.innerHTML, '

Inner content!

') // Test updates to nested content root.render(html`

${html`Updated inner!`}

`) assert_eq(el.innerHTML, '

Updated inner!

') }) test('multiple dynamic values hydrate correctly', () => { const { el } = setup(html`${'This is a'} ${html`test`} ${html`with`} ${html`parts`}`) assert_eq( el.innerHTML, 'This is a test with parts', ) }) // Attribute & Property Tests test('static attributes hydrate correctly', () => { const { el } = setup(html`

Hello, world!

`) assert_eq(el.querySelector('h1')!.className, 'title') }) test('dynamic attributes hydrate correctly', () => { const { root, el } = setup(html`

Hello, world!

`) assert_eq(el.querySelector('h1')!.getAttribute('style'), 'color: red;') // Test attribute updates root.render(html`

Hello, world!

`) assert_eq(el.querySelector('h1')!.getAttribute('style'), 'color: blue;') }) test('boolean attributes hydrate correctly', () => { const { root, el } = setup(html`
`) assert(el.querySelector('details')!.open) // Test boolean attribute toggle root.render(html`
`) assert(!el.querySelector('details')!.open) }) test('property attributes hydrate correctly', () => { const innerHTML = 'Hello!' const { el } = setup(html`
`) // assert(!el.querySelector('div')!.hasAttribute('innerHTML')) assert_eq(el.querySelector('div')!.innerHTML, innerHTML) }) test('event handlers hydrate correctly', () => { let clicks = 0 const { el } = setup(html` `) assert_eq(clicks, 0) el.querySelector('button')!.click() assert_eq(clicks, 1) }) test('data attributes hydrate correctly', () => { const { el } = setup(html`

Hello, world!

`) assert_eq(el.querySelector('h1')!.dataset.foo, 'bar') }) test('class and for attributes hydrate correctly', () => { const { el } = setup(html` `) assert_eq(el.querySelector('label')!.htmlFor, 'test-input') assert_eq(el.querySelector('label')!.className, 'label-class') }) // List & Array Hydration Tests test('basic arrays hydrate correctly', () => { const items = [html`
  • Item 1
  • `, html`
  • Item 2
  • `, html`
  • Item 3
  • `] const template = () => html` ` const { root, el } = setup(template()) assert_eq( el.innerHTML, ' ', ) // Test adding items items.push(html`
  • Item 4
  • `) root.render(template()) assert_eq( el.innerHTML, ' ', ) }) test('empty to populated arrays hydrate correctly', () => { let items: Displayable[] = [] const template = () => html` ` const { root, el } = setup(template()) assert_eq(el.innerHTML, ' ') // Add items items = [html`
  • Item 1
  • `, html`
  • Item 2
  • `] root.render(template()) assert_eq(el.innerHTML, ' ') }) test('keyed lists preserve identity during hydration', () => { const items = [keyed(html`
  • Item 1
  • `, 'a'), keyed(html`
  • Item 2
  • `, 'b')] const template = () => html` ` const { root, el } = setup(template()) const [li1, li2] = el.querySelectorAll('li') // Swap items items.reverse() root.render(template()) assert_eq( el.innerHTML, ' ', ) // Elements should maintain identity assert_eq(el.querySelectorAll('li')[0], li2) assert_eq(el.querySelectorAll('li')[1], li1) }) test('implicit keyed lists preserve identity during hydration', () => { const items = [html`
  • Item 1
  • `, html`
  • Item 2
  • `] const template = () => html` ` const { root, el } = setup(template()) const [li1, li2] = el.querySelectorAll('li') // Swap items ;[items[0], items[1]] = [items[1], items[0]] root.render(template()) assert_eq( el.innerHTML, ' ', ) // Elements should maintain identity assert_eq(el.querySelectorAll('li')[0], li2) assert_eq(el.querySelectorAll('li')[1], li1) }) test('mixed content arrays hydrate correctly', () => { const { el } = setup([1, 'text', html`element`]) assert_eq( el.innerHTML, '1textelement', ) }) // Directive Hydration Tests test('simple directives hydrate correctly', () => { const redifier: Directive = node => { if (!(node instanceof HTMLElement)) throw new Error('expected HTMLElement') node.style.color = 'red' return () => { node.style.color = '' } } const template = (directive: Directive | null) => html`
    Hello, world!
    ` const { root, el } = setup(template(redifier)) const div = el.querySelector('div')! assert_eq(div.style.cssText, 'color: red;') root.render(template(null)) assert_eq(div.style.cssText, '') }) test('attr directive hydrates correctly', () => { const template = (value: string) => html`` const { root, el } = setup(template('test-input')) assert_eq(el.querySelector('label')!.htmlFor, 'test-input') root.render(template('updated-input')) assert_eq(el.querySelector('label')!.htmlFor, 'updated-input') }) test('directives with parameters hydrate correctly', () => { function classes(value: string[]): Directive { const values = value.filter(Boolean) return node => { node.classList.add(...values) return () => { node.classList.remove(...values) } } } const { el } = setup(html`
    Hello
    `) assert_eq(el.querySelector('div')!.className, 'base a b') }) // Renderable Component Tests test('basic renderables hydrate correctly', () => { const { el } = setup({ render() { return html`

    Component content

    ` }, }) assert_eq(el.innerHTML, '

    Component content

    ') }) test('renderables with state hydrate correctly', async () => { const counter = { count: 0, render() { return html`
    Count: ${this.count}
    ` }, } const { el } = setup(counter) assert_eq(el.innerHTML, '
    Count: 0
    ') // Test state updates counter.count = 5 await invalidate(counter) assert_eq(el.innerHTML, '
    Count: 5
    ') }) test('nested renderables hydrate correctly', () => { const inner = { render() { return html`Inner` }, } const outer = { render() { return html`
    Outer: ${inner}
    ` }, } const { el } = setup(outer) assert_eq(el.innerHTML, '
    Outer: Inner
    ') }) test('renderables with lifecycle hooks hydrate correctly', () => { const sequence: string[] = [] const app = { render() { sequence.push(`render ${phase}`) return html`
    Component
    ` }, } onMount(app, () => { sequence.push(`mount ${phase}`) return () => sequence.push('cleanup') }) const { root, el } = setup(app) assert_eq(el.innerHTML, '
    Component
    ') assert_deep_eq(sequence, [ 'render server', // render on the server 'render client', // render on the client for hydration 'mount client', // mount on the client 'render client', // rerender on the client ]) // Test cleanup sequence.length = 0 root.render(null) assert_deep_eq(sequence, ['cleanup']) }) test('renderables that throw work correctly during hydration', () => { const app = { render() { throw html`
    Thrown content
    ` }, } const { el } = setup(app) assert_eq(el.innerHTML, '
    Thrown content
    ') }) // Advanced Hydration Scenarios test('deeply nested templates hydrate correctly', () => { const { el } = setup(html`
    ${html`

    Title

    `}
    ${html`

    Content with ${html`emphasis`}

    `}
    `) assert(el.querySelector('.outer')) assert(el.querySelector('h1')) assert(el.querySelector('strong')) assert_eq(el.querySelector('h1')!.textContent, 'Title') assert_eq(el.querySelector('strong')!.textContent, 'emphasis') }) test('templates with comments and whitespace hydrate correctly', () => { const { el } = setup(html`

    Content

    `) assert(el.querySelector('p')) assert_eq(el.querySelector('p')!.textContent, 'Content') }) test('mixed static and dynamic content hydrates correctly', () => { const { el } = setup(html`

    Hello, ${'World'}!

    This is static

    Count: ${42}

    `) const paragraphs = el.querySelectorAll('p') assert_eq(paragraphs.length, 3) assert_eq(paragraphs[0].textContent, 'Hello, World!') assert_eq(paragraphs[1].textContent, 'This is static') assert_eq(paragraphs[2].textContent, 'Count: 42') }) test('hydration preserves existing sibling nodes', () => { const server_html = renderToString(html`

    Hydrated content

    `) const el = document.createElement('div') el.innerHTML = `
    before
    ${server_html}
    after
    ` document.body.appendChild(el) const root = hydrate(el, html`

    Hydrated content

    `) assert_eq(el.innerHTML, '
    before

    Hydrated content

    after
    ') // Test that updates don't affect siblings root.render(html`

    Updated content

    `) assert_eq(el.innerHTML, '
    before

    Updated content

    after
    ') }) test('complex real-world template hydrates correctly', () => { const todos = [ { id: 1, text: 'Learn dhtml', completed: false }, { id: 2, text: 'Build an app', completed: true }, ] const { el } = setup(html`

    Todos

    `) assert(el.querySelector('.todo-app')) assert_eq(el.querySelectorAll('li').length, 2) assert(el.querySelector('li.completed')) assert_eq(el.querySelector('footer')!.textContent, '1 items left') // Test that checkboxes are properly set const checkboxes = el.querySelectorAll('input[type="checkbox"]') assert_eq(checkboxes.length, 2) assert(!checkboxes[0].checked) assert(checkboxes[1].checked) }) test('no end', () => { const el = document.createElement('div') el.innerHTML = `hello` let thrown = false try { hydrate(el, 'hello') } catch (error) { thrown = true assert(error instanceof Error) if (__DEV__) assert(error.message.includes('Could not find hydration end comment.')) } assert(thrown) }) test('renderable passthrough errors', () => { let thrown = false const oops = new Error('oops') let count = 0 try { setup({ render() { if (++count === 2) throw oops return 'hello' }, }) } catch (error) { thrown = true assert(error === oops) } assert(thrown) }) test('hydration of deep nesting', async () => { const DEPTH = 10 const leaf = { text: 'hello!', render() { return this.text }, } let app: Renderable = leaf for (let i = 0; i < DEPTH; i++) { const inner = app app = { render: () => inner } } const { el } = setup(app) assert_eq(el.innerHTML, ''.repeat(DEPTH + 1) + 'hello!' + ''.repeat(DEPTH + 1)) leaf.text = 'goodbye' await invalidate(leaf) assert_eq(el.innerHTML, ''.repeat(DEPTH + 1) + 'goodbye' + ''.repeat(DEPTH + 1)) }) test('hydration mismatch: tag name', () => { const el = document.createElement('div') el.innerHTML = renderToString(html`

    Hello!

    `) let thrown = false try { hydrate(el, html`

    Hello!

    `) } catch (error) { thrown = true assert(error instanceof Error) assert(error.message.includes('Tag name mismatch')) } assert(thrown) })