a post-component library for building user-interfaces on the web.
at push-kluylotpyvvr 547 lines 10 kB view raw
1import { html } 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', () => { 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 invalidate(app) 33 assert_eq(el.innerHTML, 'Count: 1') 34 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', () => { 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 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 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', () => { 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 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 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 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', () => { 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 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 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 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', () => { 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 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', () => { 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 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', () => { 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 invalidate(app1) 474 assert_eq(el.textContent, 'app2') 475}) 476 477if (__DEV__) { 478 test('invalidate throws error when renderable has not been rendered', () => { 479 const app = { 480 render() { 481 return 'never rendered' 482 }, 483 } 484 485 try { 486 invalidate(app) 487 assert(false, 'Expected error to be thrown') 488 } catch (error) { 489 assert(error instanceof Error) 490 assert(/the renderable has not been rendered/.test(error.message)) 491 } 492 }) 493} 494 495test('onMount called on already mounted renderable executes immediately', () => { 496 const { root } = setup() 497 498 let mounted = 0 499 let unmounted = 0 500 501 const app = { 502 render() { 503 return 'app' 504 }, 505 } 506 507 root.render(app) 508 509 onMount(app, () => { 510 mounted++ 511 return () => { 512 unmounted++ 513 } 514 }) 515 516 assert_eq(mounted, 1) 517 assert_eq(unmounted, 0) 518 519 root.render(null) 520 assert_eq(unmounted, 1) 521}) 522 523test('invalidating a parent does not re-render a child', () => { 524 const { root, el } = setup() 525 526 let renders = 0 527 const child = { 528 render() { 529 renders++ 530 return 'child' 531 }, 532 } 533 534 const parent = { 535 render() { 536 return child 537 }, 538 } 539 540 root.render(parent) 541 assert_eq(el.innerHTML, 'child') 542 assert_eq(renders, 1) 543 544 invalidate(parent) 545 assert_eq(el.innerHTML, 'child') 546 assert_eq(renders, 1) 547})