a post-component library for building user-interfaces on the web.
1import { html, keyed, type Displayable, type Renderable } from 'dhtml'
2import { attr, hydrate, invalidate, onMount, type Directive, type Root } from 'dhtml/client'
3import { renderToString } from 'dhtml/server'
4import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts'
5
6let phase: 'server' | 'client' | null = null
7
8function setup(template: Displayable): { root: Root; el: HTMLDivElement } {
9 const el = document.createElement('div')
10 phase = 'server'
11 el.innerHTML = renderToString(template)
12 document.body.appendChild(el)
13
14 phase = 'client'
15 const root = hydrate(el, template)
16
17 phase = null
18 return { root, el }
19}
20
21// Basic Hydration Tests
22test('basic html hydrates correctly', () => {
23 const { root, el } = setup(html`<h1>Hello, world!</h1>`)
24
25 assert_eq(el.innerHTML, '<!--?[--><h1>Hello, world!</h1><!--?]-->')
26
27 // Test that subsequent renders work
28 root.render(html`<h2>Updated!</h2>`)
29 assert_eq(el.innerHTML, '<!--?[--><h2>Updated!</h2><!--?]-->')
30})
31
32test('dynamic content hydrates correctly', () => {
33 const template = (n: number) => html`<h1>Hello, ${n}!</h1>`
34 const { root, el } = setup(template(42))
35
36 assert_eq(el.innerHTML, '<!--?[--><h1>Hello, <!--?[-->42<!--?]-->!</h1><!--?]-->')
37
38 // Test dynamic updates
39 root.render(template(84))
40 assert_eq(el.innerHTML, '<!--?[--><h1>Hello, <!--?[-->84<!--?]-->!</h1><!--?]-->')
41})
42
43test('nested templates hydrate correctly', () => {
44 const { root, el } = setup(html`<h1>${html`Inner content!`}</h1>`)
45
46 assert_eq(el.innerHTML, '<!--?[--><h1><!--?[-->Inner content!<!--?]--></h1><!--?]-->')
47
48 // Test updates to nested content
49 root.render(html`<h1>${html`Updated inner!`}</h1>`)
50 assert_eq(el.innerHTML, '<!--?[--><h1>Updated inner!</h1><!--?]-->')
51})
52
53test('multiple dynamic values hydrate correctly', () => {
54 const { el } = setup(html`<span>${'This is a'}</span> ${html`test`} ${html`with`} ${html`parts`}`)
55
56 assert_eq(
57 el.innerHTML,
58 '<!--?[--><span><!--?[-->This is a<!--?]--></span> <!--?[-->test<!--?]--> <!--?[-->with<!--?]--> <!--?[-->parts<!--?]--><!--?]-->',
59 )
60})
61
62// Attribute & Property Tests
63test('static attributes hydrate correctly', () => {
64 const { el } = setup(html`<h1 class="title">Hello, world!</h1>`)
65
66 assert_eq(el.querySelector('h1')!.className, 'title')
67})
68
69test('dynamic attributes hydrate correctly', () => {
70 const { root, el } = setup(html`<h1 style=${'color: red'}>Hello, world!</h1>`)
71
72 assert_eq(el.querySelector('h1')!.getAttribute('style'), 'color: red;')
73
74 // Test attribute updates
75 root.render(html`<h1 style=${'color: blue'}>Hello, world!</h1>`)
76 assert_eq(el.querySelector('h1')!.getAttribute('style'), 'color: blue;')
77})
78
79test('boolean attributes hydrate correctly', () => {
80 const { root, el } = setup(html`<details open=${true}></details>`)
81
82 assert(el.querySelector('details')!.open)
83
84 // Test boolean attribute toggle
85 root.render(html`<details open=${false}></details>`)
86 assert(!el.querySelector('details')!.open)
87})
88
89test('property attributes hydrate correctly', () => {
90 const innerHTML = '<span>Hello!</span>'
91 const { el } = setup(html`<div innerHTML=${innerHTML}></div>`)
92
93 // assert(!el.querySelector('div')!.hasAttribute('innerHTML'))
94 assert_eq(el.querySelector('div')!.innerHTML, innerHTML)
95})
96
97test('event handlers hydrate correctly', () => {
98 let clicks = 0
99 const { el } = setup(html`
100 <button
101 onclick=${() => {
102 clicks++
103 }}
104 >
105 Click me
106 </button>
107 `)
108
109 assert_eq(clicks, 0)
110 el.querySelector('button')!.click()
111 assert_eq(clicks, 1)
112})
113
114test('data attributes hydrate correctly', () => {
115 const { el } = setup(html`<h1 data-foo=${'bar'}>Hello, world!</h1>`)
116
117 assert_eq(el.querySelector('h1')!.dataset.foo, 'bar')
118})
119
120test('class and for attributes hydrate correctly', () => {
121 const { el } = setup(html`
122 <label for=${'test-input'} class=${'label-class'}>Label</label>
123 <input id="test-input" />
124 `)
125
126 assert_eq(el.querySelector('label')!.htmlFor, 'test-input')
127 assert_eq(el.querySelector('label')!.className, 'label-class')
128})
129
130// List & Array Hydration Tests
131test('basic arrays hydrate correctly', () => {
132 const items = [html`<li>Item 1</li>`, html`<li>Item 2</li>`, html`<li>Item 3</li>`]
133 const template = () => html`
134 <ul>
135 ${items}
136 </ul>
137 `
138 const { root, el } = setup(template())
139
140 assert_eq(
141 el.innerHTML,
142 '<!--?[--> <ul> <!--?[--><!--?[--><li>Item 1</li><!--?]--><!--?[--><li>Item 2</li><!--?]--><!--?[--><li>Item 3</li><!--?]--><!--?]--> </ul> <!--?]-->',
143 )
144
145 // Test adding items
146 items.push(html`<li>Item 4</li>`)
147 root.render(template())
148 assert_eq(
149 el.innerHTML,
150 '<!--?[--> <ul> <!--?[--><!--?[--><li>Item 1</li><!--?]--><!--?[--><li>Item 2</li><!--?]--><!--?[--><li>Item 3</li><!--?]--><li>Item 4</li><!--?]--> </ul> <!--?]-->',
151 )
152})
153
154test('empty to populated arrays hydrate correctly', () => {
155 let items: Displayable[] = []
156 const template = () => html`
157 <ul>
158 ${items}
159 </ul>
160 `
161 const { root, el } = setup(template())
162
163 assert_eq(el.innerHTML, '<!--?[--> <ul> <!--?[--><!--?]--> </ul> <!--?]-->')
164
165 // Add items
166 items = [html`<li>Item 1</li>`, html`<li>Item 2</li>`]
167 root.render(template())
168 assert_eq(el.innerHTML, '<!--?[--> <ul> <!--?[--><li>Item 1</li><li>Item 2</li><!--?]--> </ul> <!--?]-->')
169})
170
171test('keyed lists preserve identity during hydration', () => {
172 const items = [keyed(html`<li>Item 1</li>`, 'a'), keyed(html`<li>Item 2</li>`, 'b')]
173 const template = () => html`
174 <ul>
175 ${items}
176 </ul>
177 `
178 const { root, el } = setup(template())
179
180 const [li1, li2] = el.querySelectorAll('li')
181
182 // Swap items
183 items.reverse()
184 root.render(template())
185 assert_eq(
186 el.innerHTML,
187 '<!--?[--> <ul> <!--?[--><!--?[--><li>Item 2</li><!--?]--><!--?[--><li>Item 1</li><!--?]--><!--?]--> </ul> <!--?]-->',
188 )
189
190 // Elements should maintain identity
191 assert_eq(el.querySelectorAll('li')[0], li2)
192 assert_eq(el.querySelectorAll('li')[1], li1)
193})
194
195test('implicit keyed lists preserve identity during hydration', () => {
196 const items = [html`<li>Item 1</li>`, html`<li>Item 2</li>`]
197 const template = () => html`
198 <ul>
199 ${items}
200 </ul>
201 `
202 const { root, el } = setup(template())
203
204 const [li1, li2] = el.querySelectorAll('li')
205
206 // Swap items
207 ;[items[0], items[1]] = [items[1], items[0]]
208 root.render(template())
209 assert_eq(
210 el.innerHTML,
211 '<!--?[--> <ul> <!--?[--><!--?[--><li>Item 2</li><!--?]--><!--?[--><li>Item 1</li><!--?]--><!--?]--> </ul> <!--?]-->',
212 )
213
214 // Elements should maintain identity
215 assert_eq(el.querySelectorAll('li')[0], li2)
216 assert_eq(el.querySelectorAll('li')[1], li1)
217})
218
219test('mixed content arrays hydrate correctly', () => {
220 const { el } = setup([1, 'text', html`<span>element</span>`])
221 assert_eq(
222 el.innerHTML,
223 '<!--?[--><!--?[-->1<!--?]--><!--?[-->text<!--?]--><!--?[--><span>element</span><!--?]--><!--?]-->',
224 )
225})
226
227// Directive Hydration Tests
228test('simple directives hydrate correctly', () => {
229 const redifier: Directive = node => {
230 if (!(node instanceof HTMLElement)) throw new Error('expected HTMLElement')
231 node.style.color = 'red'
232 return () => {
233 node.style.color = ''
234 }
235 }
236
237 const template = (directive: Directive | null) => html`<div ${directive}>Hello, world!</div>`
238 const { root, el } = setup(template(redifier))
239
240 const div = el.querySelector('div')!
241 assert_eq(div.style.cssText, 'color: red;')
242
243 root.render(template(null))
244 assert_eq(div.style.cssText, '')
245})
246
247test('attr directive hydrates correctly', () => {
248 const template = (value: string) => html`<label ${attr('for', value)}>Label</label>`
249 const { root, el } = setup(template('test-input'))
250
251 assert_eq(el.querySelector('label')!.htmlFor, 'test-input')
252
253 root.render(template('updated-input'))
254 assert_eq(el.querySelector('label')!.htmlFor, 'updated-input')
255})
256
257test('directives with parameters hydrate correctly', () => {
258 function classes(value: string[]): Directive {
259 const values = value.filter(Boolean)
260 return node => {
261 node.classList.add(...values)
262 return () => {
263 node.classList.remove(...values)
264 }
265 }
266 }
267
268 const { el } = setup(html`<div class="base" ${classes(['a', 'b'])}>Hello</div>`)
269
270 assert_eq(el.querySelector('div')!.className, 'base a b')
271})
272
273// Renderable Component Tests
274test('basic renderables hydrate correctly', () => {
275 const { el } = setup({
276 render() {
277 return html`<h1>Component content</h1>`
278 },
279 })
280
281 assert_eq(el.innerHTML, '<!--?[--><h1>Component content</h1><!--?]-->')
282})
283
284test('renderables with state hydrate correctly', async () => {
285 const counter = {
286 count: 0,
287 render() {
288 return html`<div>Count: ${this.count}</div>`
289 },
290 }
291
292 const { el } = setup(counter)
293
294 assert_eq(el.innerHTML, '<!--?[--><div>Count: <!--?[-->0<!--?]--></div><!--?]-->')
295
296 // Test state updates
297 counter.count = 5
298 await invalidate(counter)
299 assert_eq(el.innerHTML, '<!--?[--><div>Count: <!--?[-->5<!--?]--></div><!--?]-->')
300})
301
302test('nested renderables hydrate correctly', () => {
303 const inner = {
304 render() {
305 return html`<span>Inner</span>`
306 },
307 }
308
309 const outer = {
310 render() {
311 return html`<div>Outer: ${inner}</div>`
312 },
313 }
314
315 const { el } = setup(outer)
316
317 assert_eq(el.innerHTML, '<!--?[--><div>Outer: <!--?[--><span>Inner</span><!--?]--></div><!--?]-->')
318})
319
320test('renderables with lifecycle hooks hydrate correctly', () => {
321 const sequence: string[] = []
322
323 const app = {
324 render() {
325 sequence.push(`render ${phase}`)
326 return html`<div>Component</div>`
327 },
328 }
329
330 onMount(app, () => {
331 sequence.push(`mount ${phase}`)
332 return () => sequence.push('cleanup')
333 })
334
335 const { root, el } = setup(app)
336
337 assert_eq(el.innerHTML, '<!--?[--><div>Component</div><!--?]-->')
338 assert_deep_eq(sequence, [
339 'render server', // render on the server
340 'render client', // render on the client for hydration
341 'mount client', // mount on the client
342 'render client', // rerender on the client
343 ])
344
345 // Test cleanup
346 sequence.length = 0
347 root.render(null)
348 assert_deep_eq(sequence, ['cleanup'])
349})
350
351test('renderables that throw work correctly during hydration', () => {
352 const app = {
353 render() {
354 throw html`<div>Thrown content</div>`
355 },
356 }
357
358 const { el } = setup(app)
359
360 assert_eq(el.innerHTML, '<!--?[--><div>Thrown content</div><!--?]-->')
361})
362
363// Advanced Hydration Scenarios
364test('deeply nested templates hydrate correctly', () => {
365 const { el } = setup(html`
366 <div class="outer">
367 <section>
368 <header>${html`<h1>Title</h1>`}</header>
369 <main>
370 ${html`
371 <article>
372 <p>Content with ${html`<strong>emphasis</strong>`}</p>
373 </article>
374 `}
375 </main>
376 </section>
377 </div>
378 `)
379
380 assert(el.querySelector('.outer'))
381 assert(el.querySelector('h1'))
382 assert(el.querySelector('strong'))
383 assert_eq(el.querySelector('h1')!.textContent, 'Title')
384 assert_eq(el.querySelector('strong')!.textContent, 'emphasis')
385})
386
387test('templates with comments and whitespace hydrate correctly', () => {
388 const { el } = setup(html`
389 <div>
390 <!-- This is a comment -->
391 <p>Content</p>
392 <!-- Another comment -->
393 </div>
394 `)
395
396 assert(el.querySelector('p'))
397 assert_eq(el.querySelector('p')!.textContent, 'Content')
398})
399
400test('mixed static and dynamic content hydrates correctly', () => {
401 const { el } = setup(html`
402 <div>
403 <p>Hello, ${'World'}!</p>
404 <p>This is static</p>
405 <p>Count: ${42}</p>
406 </div>
407 `)
408
409 const paragraphs = el.querySelectorAll('p')
410 assert_eq(paragraphs.length, 3)
411 assert_eq(paragraphs[0].textContent, 'Hello, World!')
412 assert_eq(paragraphs[1].textContent, 'This is static')
413 assert_eq(paragraphs[2].textContent, 'Count: 42')
414})
415
416test('hydration preserves existing sibling nodes', () => {
417 const server_html = renderToString(html`<h1>Hydrated content</h1>`)
418
419 const el = document.createElement('div')
420 el.innerHTML = `<div>before</div>${server_html}<div>after</div>`
421 document.body.appendChild(el)
422
423 const root = hydrate(el, html`<h1>Hydrated content</h1>`)
424
425 assert_eq(el.innerHTML, '<div>before</div><!--?[--><h1>Hydrated content</h1><!--?]--><div>after</div>')
426
427 // Test that updates don't affect siblings
428 root.render(html`<h2>Updated content</h2>`)
429 assert_eq(el.innerHTML, '<div>before</div><!--?[--><h2>Updated content</h2><!--?]--><div>after</div>')
430})
431
432test('complex real-world template hydrates correctly', () => {
433 const todos = [
434 { id: 1, text: 'Learn dhtml', completed: false },
435 { id: 2, text: 'Build an app', completed: true },
436 ]
437
438 const { el } = setup(html`
439 <div class="todo-app">
440 <header>
441 <h1>Todos</h1>
442 <input type="text" placeholder="Add todo..." />
443 </header>
444 <ul class="todo-list">
445 ${todos.map(
446 todo => html`
447 <li class=${todo.completed ? 'completed' : ''}>
448 <input type="checkbox" checked=${todo.completed} />
449 <span>${todo.text}</span>
450 <button class="delete">×</button>
451 </li>
452 `,
453 )}
454 </ul>
455 <footer>${todos.filter(t => !t.completed).length} items left</footer>
456 </div>
457 `)
458
459 assert(el.querySelector('.todo-app'))
460 assert_eq(el.querySelectorAll('li').length, 2)
461 assert(el.querySelector('li.completed'))
462 assert_eq(el.querySelector('footer')!.textContent, '1 items left')
463
464 // Test that checkboxes are properly set
465 const checkboxes = el.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
466 assert_eq(checkboxes.length, 2)
467 assert(!checkboxes[0].checked)
468 assert(checkboxes[1].checked)
469})
470
471test('no end', () => {
472 const el = document.createElement('div')
473 el.innerHTML = `<?[>hello`
474
475 let thrown = false
476
477 try {
478 hydrate(el, 'hello')
479 } catch (error) {
480 thrown = true
481 assert(error instanceof Error)
482 if (__DEV__) assert(error.message.includes('Could not find hydration end comment.'))
483 }
484
485 assert(thrown)
486})
487
488test('renderable passthrough errors', () => {
489 let thrown = false
490
491 const oops = new Error('oops')
492 let count = 0
493
494 try {
495 setup({
496 render() {
497 if (++count === 2) throw oops
498 return 'hello'
499 },
500 })
501 } catch (error) {
502 thrown = true
503 assert(error === oops)
504 }
505
506 assert(thrown)
507})
508
509test('hydration of deep nesting', async () => {
510 const DEPTH = 10
511
512 const leaf = {
513 text: 'hello!',
514 render() {
515 return this.text
516 },
517 }
518 let app: Renderable = leaf
519 for (let i = 0; i < DEPTH; i++) {
520 const inner = app
521 app = { render: () => inner }
522 }
523
524 const { el } = setup(app)
525
526 assert_eq(el.innerHTML, '<!--?[-->'.repeat(DEPTH + 1) + 'hello!' + '<!--?]-->'.repeat(DEPTH + 1))
527
528 leaf.text = 'goodbye'
529 await invalidate(leaf)
530
531 assert_eq(el.innerHTML, '<!--?[-->'.repeat(DEPTH + 1) + 'goodbye' + '<!--?]-->'.repeat(DEPTH + 1))
532})
533
534test('hydration mismatch: tag name', () => {
535 const el = document.createElement('div')
536 el.innerHTML = renderToString(html`<h1>Hello!</h1>`)
537 let thrown = false
538
539 try {
540 hydrate(el, html`<h2>Hello!</h2>`)
541 } catch (error) {
542 thrown = true
543 assert(error instanceof Error)
544 assert(error.message.includes('Tag name mismatch'))
545 }
546
547 assert(thrown)
548})