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