a post-component library for building user-interfaces on the web.
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})