a post-component library for building user-interfaces on the web.
at main 621 lines 12 kB view raw
1import { html, type Displayable } from 'dhtml' 2import { invalidate, onMount, onUnmount } from 'dhtml/client' 3import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' 4import { setup } from './setup.ts' 5 6test('renderables work correctly', async () => { 7 const { root, el } = setup() 8 9 root.render( 10 html`${{ 11 render() { 12 return html`<h1>Hello, world!</h1>` 13 }, 14 }}`, 15 ) 16 assert_eq(el.innerHTML, '<h1>Hello, world!</h1>') 17 18 const app = { 19 i: 0, 20 render() { 21 return html`Count: ${this.i++}` 22 }, 23 } 24 root.render(app) 25 assert_eq(el.innerHTML, 'Count: 0') 26 27 // rerendering a valid renderable should noop: 28 root.render(app) 29 assert_eq(el.innerHTML, 'Count: 0') 30 31 // but invalidating it shouldn't: 32 await invalidate(app) 33 assert_eq(el.innerHTML, 'Count: 1') 34 await invalidate(app) 35 assert_eq(el.innerHTML, 'Count: 2') 36 assert_eq(app.i, 3) 37}) 38 39test('renderables handle undefined correctly', () => { 40 const { root, el } = setup() 41 42 root.render({ 43 // @ts-expect-error 44 render() {}, 45 }) 46 47 assert_eq(el.innerHTML, '') 48}) 49 50test('renderables can throw instead of returning', () => { 51 const { root, el } = setup() 52 53 root.render({ 54 render() { 55 throw html`this was thrown` 56 }, 57 }) 58 59 assert_eq(el.innerHTML, 'this was thrown') 60}) 61 62test('onMount calls in the right order', async () => { 63 const { root, el } = setup() 64 65 const sequence: string[] = [] 66 67 const inner = { 68 render() { 69 sequence.push('inner render') 70 return 'inner' 71 }, 72 } 73 onMount(inner, () => { 74 sequence.push('inner mount') 75 return () => { 76 sequence.push('inner cleanup') 77 } 78 }) 79 80 const outer = { 81 show: true, 82 render() { 83 sequence.push('outer render') 84 if (!this.show) return null 85 return inner 86 }, 87 } 88 89 onMount(outer, () => { 90 sequence.push('outer mount') 91 return () => { 92 sequence.push('outer cleanup') 93 } 94 }) 95 96 outer.show = true 97 root.render(outer) 98 assert_eq(el.innerHTML, 'inner') 99 assert_deep_eq(sequence, ['outer mount', 'outer render', 'inner mount', 'inner render']) 100 sequence.length = 0 101 102 outer.show = false 103 await invalidate(outer) 104 assert_eq(el.innerHTML, '') 105 assert_deep_eq(sequence, ['outer render', 'inner cleanup']) 106 sequence.length = 0 107 108 outer.show = true 109 await invalidate(outer) 110 assert_eq(el.innerHTML, 'inner') 111 // inner is mounted a second time because of the above cleanup 112 assert_deep_eq(sequence, ['outer render', 'inner mount', 'inner render']) 113 sequence.length = 0 114}) 115 116test('onMount registers multiple callbacks', () => { 117 const { root } = setup() 118 119 const sequence: string[] = [] 120 121 const app = { 122 render() { 123 return 'app' 124 }, 125 } 126 127 onMount(app, () => { 128 sequence.push('mount 1') 129 return () => sequence.push('cleanup 1') 130 }) 131 132 onMount(app, () => { 133 sequence.push('mount 2') 134 return () => sequence.push('cleanup 2') 135 }) 136 137 root.render(app) 138 assert_deep_eq(sequence, ['mount 1', 'mount 2']) 139 sequence.length = 0 140 141 root.render(null) 142 assert_deep_eq(sequence, ['cleanup 1', 'cleanup 2']) 143}) 144 145test('onMount registers a fixed callback multiple times', () => { 146 const { root } = setup() 147 148 const sequence: string[] = [] 149 150 function callback() { 151 sequence.push('mount') 152 return () => sequence.push('cleanup') 153 } 154 155 const app = { 156 render() { 157 return 'app' 158 }, 159 } 160 161 onMount(app, callback) 162 onMount(app, callback) 163 164 root.render(app) 165 assert_deep_eq(sequence, ['mount', 'mount']) 166 sequence.length = 0 167 168 root.render(null) 169 assert_deep_eq(sequence, ['cleanup', 'cleanup']) 170}) 171 172test('onMount registers callbacks outside of render', () => { 173 const { root } = setup() 174 175 const sequence: string[] = [] 176 177 const app = { 178 render() { 179 sequence.push('render') 180 return 'app' 181 }, 182 } 183 184 onMount(app, () => { 185 sequence.push('mount') 186 return () => sequence.push('cleanup') 187 }) 188 189 assert_deep_eq(sequence, []) 190 191 root.render(app) 192 assert_deep_eq(sequence, ['mount', 'render']) 193 sequence.length = 0 194 195 root.render(null) 196 assert_deep_eq(sequence, ['cleanup']) 197}) 198 199test('onMount is called immediately on a mounted renderable', () => { 200 const { root } = setup() 201 202 const app = { 203 render() { 204 return 'app' 205 }, 206 } 207 208 root.render(app) 209 210 let calls = 0 211 onMount(app, () => { 212 calls++ 213 }) 214 assert_eq(calls, 1) 215}) 216 217test('onUnmount deep works correctly', async () => { 218 const { root, el } = setup() 219 220 const sequence: string[] = [] 221 222 const inner = { 223 render() { 224 sequence.push('inner render') 225 return 'inner' 226 }, 227 } 228 229 onUnmount(inner, () => { 230 sequence.push('inner abort') 231 }) 232 233 const outer = { 234 show: true, 235 render() { 236 sequence.push('outer render') 237 if (!this.show) return null 238 return inner 239 }, 240 } 241 242 onUnmount(outer, () => { 243 sequence.push('outer abort') 244 }) 245 246 outer.show = true 247 root.render(outer) 248 assert_eq(el.innerHTML, 'inner') 249 assert_deep_eq(sequence, ['outer render', 'inner render']) 250 sequence.length = 0 251 252 outer.show = false 253 await invalidate(outer) 254 assert_eq(el.innerHTML, '') 255 assert_deep_eq(sequence, ['outer render', 'inner abort']) 256 sequence.length = 0 257 258 outer.show = true 259 await invalidate(outer) 260 assert_eq(el.innerHTML, 'inner') 261 assert_deep_eq(sequence, ['outer render', 'inner render']) 262 sequence.length = 0 263 264 outer.show = false 265 await invalidate(outer) 266 assert_eq(el.innerHTML, '') 267 assert_deep_eq(sequence, ['outer render', 'inner abort']) 268 sequence.length = 0 269}) 270 271test('onUnmount shallow works correctly', async () => { 272 const { root, el } = setup() 273 274 const sequence: string[] = [] 275 276 const inner = { 277 render() { 278 sequence.push('inner render') 279 return 'inner' 280 }, 281 } 282 283 onUnmount(inner, () => { 284 sequence.push('inner abort') 285 }) 286 287 const outer = { 288 attached: false, 289 show: true, 290 render() { 291 sequence.push('outer render') 292 if (!this.attached) { 293 this.attached = true 294 onUnmount(this, () => { 295 this.attached = false 296 sequence.push('outer abort') 297 }) 298 } 299 return html`${this.show ? inner : null}` 300 }, 301 } 302 303 outer.show = true 304 root.render(outer) 305 assert_eq(el.innerHTML, 'inner') 306 assert_deep_eq(sequence, ['outer render', 'inner render']) 307 sequence.length = 0 308 309 outer.show = false 310 await invalidate(outer) 311 assert_eq(el.innerHTML, '') 312 assert_deep_eq(sequence, ['outer render', 'inner abort']) 313 sequence.length = 0 314 315 outer.show = true 316 await invalidate(outer) 317 assert_eq(el.innerHTML, 'inner') 318 assert_deep_eq(sequence, ['outer render', 'inner render']) 319 sequence.length = 0 320 321 outer.show = false 322 await invalidate(outer) 323 assert_eq(el.innerHTML, '') 324 assert_deep_eq(sequence, ['outer render', 'inner abort']) 325 sequence.length = 0 326}) 327 328test('onUnmount works externally', async () => { 329 const { root, el } = setup() 330 331 const app = { 332 render() { 333 return [1, 2, 3].map(i => html`<div>${i}</div>`) 334 }, 335 } 336 337 let unmounts = 0 338 onUnmount(app, () => { 339 unmounts++ 340 }) 341 342 root.render(app) 343 assert_eq(el.innerHTML, '<div>1</div><div>2</div><div>3</div>') 344 assert_eq(unmounts, 0) 345 346 root.render(null) 347 assert_eq(unmounts, 1) 348}) 349 350test('onMount works for repeated mounts', () => { 351 const { root } = setup() 352 let mounted = 0 353 354 const app = { 355 render() { 356 return html`${mounted}` 357 }, 358 } 359 onMount(app, () => { 360 mounted++ 361 return () => { 362 mounted-- 363 } 364 }) 365 366 assert_eq(mounted, 0) 367 368 for (let i = 0; i < 10; i++) { 369 root.render(app) 370 assert_eq(mounted, 1) 371 372 root.render(null) 373 assert_eq(mounted, 0) 374 } 375}) 376 377test('renderables can be rendered in multiple places at once', async () => { 378 const { root: root1, el: el1 } = setup() 379 const { root: root2, el: el2 } = setup() 380 381 let mounted = 0 382 383 const app = { 384 value: 'shared', 385 render() { 386 return this.value 387 }, 388 } 389 390 onMount(app, () => { 391 mounted++ 392 return () => mounted-- 393 }) 394 395 // Render in first location 396 root1.render(app) 397 assert_eq(el1.innerHTML, 'shared') 398 assert_eq(mounted, 1) 399 400 // Render in second location - should NOT mount again (mount only called on first mount) 401 root2.render(app) 402 assert_eq(el2.innerHTML, 'shared') 403 assert_eq(mounted, 1) // Still 1, not 2 404 405 // Update the renderable - both should update 406 app.value = 'updated' 407 await invalidate(app) 408 assert_eq(el1.innerHTML, 'updated') 409 assert_eq(el2.innerHTML, 'updated') 410 411 // Remove from first location - should NOT unmount yet 412 root1.render(null) 413 assert_eq(mounted, 1) // Still mounted in second location 414 assert_eq(el2.innerHTML, 'updated') // Second location still works 415 416 // Remove from second location - NOW it should unmount 417 root2.render(null) 418 assert_eq(mounted, 0) // Now unmounted 419}) 420 421test('renderables can be rendered in multiple places at once with a single root', async () => { 422 const { root, el } = setup() 423 424 let mounted = 0 425 426 const thing = { 427 value: 'shared', 428 render() { 429 return this.value 430 }, 431 } 432 433 onMount(thing, () => { 434 mounted++ 435 return () => mounted-- 436 }) 437 438 root.render(html`<span>${thing}</span><span>${thing}</span>`) 439 440 assert_eq(mounted, 1) 441 assert_eq(el.innerHTML, '<span>shared</span><span>shared</span>') 442 443 thing.value = 'updated' 444 await invalidate(thing) 445 assert_eq(mounted, 1) 446 assert_eq(el.innerHTML, '<span>updated</span><span>updated</span>') 447 448 root.render(null) 449 assert_eq(mounted, 0) 450}) 451 452test('invalidating an unmounted renderable does nothing', async () => { 453 const { root, el } = setup() 454 455 const app1 = { 456 render() { 457 return 'app1' 458 }, 459 } 460 461 const app2 = { 462 render() { 463 return 'app2' 464 }, 465 } 466 467 root.render(app1) 468 assert_eq(el.textContent, 'app1') 469 470 root.render(app2) 471 assert_eq(el.textContent, 'app2') 472 473 await invalidate(app1) 474 assert_eq(el.textContent, 'app2') 475}) 476 477test('onMount called on already mounted renderable executes immediately', () => { 478 const { root } = setup() 479 480 let mounted = 0 481 let unmounted = 0 482 483 const app = { 484 render() { 485 return 'app' 486 }, 487 } 488 489 root.render(app) 490 491 onMount(app, () => { 492 mounted++ 493 return () => { 494 unmounted++ 495 } 496 }) 497 498 assert_eq(mounted, 1) 499 assert_eq(unmounted, 0) 500 501 root.render(null) 502 assert_eq(unmounted, 1) 503}) 504 505test('invalidating a parent does not re-render a child', async () => { 506 const { root, el } = setup() 507 508 let renders = 0 509 const child = { 510 render() { 511 renders++ 512 return 'child' 513 }, 514 } 515 516 const parent = { 517 render() { 518 return child 519 }, 520 } 521 522 root.render(parent) 523 assert_eq(el.innerHTML, 'child') 524 assert_eq(renders, 1) 525 526 await invalidate(parent) 527 assert_eq(el.innerHTML, 'child') 528 assert_eq(renders, 1) 529}) 530 531test('invalidating parent during child render triggers update', async () => { 532 const { root, el } = setup() 533 534 let promise: Promise<void> 535 const item = { 536 render() { 537 app.loading = true 538 promise = invalidate(app) 539 return 'created' 540 }, 541 } 542 543 const app = { 544 loading: false, 545 546 render() { 547 if (this.loading) return 'loading' 548 return item 549 }, 550 } 551 552 root.render(app) 553 assert(promise!) 554 await promise 555 assert_eq(el.innerHTML, 'loading') 556}) 557 558test('invalidating grandparent during child render triggers update', async () => { 559 const { root, el } = setup() 560 561 let promise: Promise<void> 562 const item = { 563 render() { 564 app.loading = true 565 promise = invalidate(app) 566 return 'created' 567 }, 568 } 569 570 const middle = { 571 item: null as Displayable, 572 573 render() { 574 return this.item 575 }, 576 } 577 578 const app = { 579 loading: false, 580 581 render() { 582 if (this.loading) return 'loading' 583 return middle 584 }, 585 } 586 587 root.render(app) 588 assert_eq(el.innerHTML, '') 589 590 middle.item = item 591 await invalidate(middle) 592 assert(promise!) 593 await promise 594 assert_eq(el.innerHTML, 'loading') 595}) 596 597test('invalidate drains reinvalidation of the same renderable before resolve', async () => { 598 const { root, el } = setup() 599 600 let state = 0 601 let nested: Promise<void> | undefined 602 const app = { 603 render() { 604 if (state === 1) { 605 state = 2 606 nested = invalidate(app) 607 } 608 return '' + state 609 }, 610 } 611 612 root.render(app) 613 assert_eq(el.innerHTML, '0') 614 615 state = 1 616 const promise = invalidate(app) 617 await promise 618 assert_eq(el.innerHTML, '2') 619 assert(nested!) 620 await nested 621})