a post-component library for building user-interfaces on the web.
at main 548 lines 15 kB view raw
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})