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`
{
clicks++
}}
>
Click me
`)
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`
Label
`)
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`Label `
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`
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`
`)
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.filter(t => !t.completed).length} items left
`)
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)
})