import { html, type Displayable } from 'dhtml' import { invalidate, onMount, onUnmount } from 'dhtml/client' import { assert, assert_deep_eq, assert_eq, test } from '../../../scripts/test/test.ts' import { setup } from './setup.ts' test('renderables work correctly', async () => { const { root, el } = setup() root.render( html`${{ render() { return html`

Hello, world!

` }, }}`, ) assert_eq(el.innerHTML, '

Hello, world!

') const app = { i: 0, render() { return html`Count: ${this.i++}` }, } root.render(app) assert_eq(el.innerHTML, 'Count: 0') // rerendering a valid renderable should noop: root.render(app) assert_eq(el.innerHTML, 'Count: 0') // but invalidating it shouldn't: await invalidate(app) assert_eq(el.innerHTML, 'Count: 1') await invalidate(app) assert_eq(el.innerHTML, 'Count: 2') assert_eq(app.i, 3) }) test('renderables handle undefined correctly', () => { const { root, el } = setup() root.render({ // @ts-expect-error render() {}, }) assert_eq(el.innerHTML, '') }) test('renderables can throw instead of returning', () => { const { root, el } = setup() root.render({ render() { throw html`this was thrown` }, }) assert_eq(el.innerHTML, 'this was thrown') }) test('onMount calls in the right order', async () => { const { root, el } = setup() const sequence: string[] = [] const inner = { render() { sequence.push('inner render') return 'inner' }, } onMount(inner, () => { sequence.push('inner mount') return () => { sequence.push('inner cleanup') } }) const outer = { show: true, render() { sequence.push('outer render') if (!this.show) return null return inner }, } onMount(outer, () => { sequence.push('outer mount') return () => { sequence.push('outer cleanup') } }) outer.show = true root.render(outer) assert_eq(el.innerHTML, 'inner') assert_deep_eq(sequence, ['outer mount', 'outer render', 'inner mount', 'inner render']) sequence.length = 0 outer.show = false await invalidate(outer) assert_eq(el.innerHTML, '') assert_deep_eq(sequence, ['outer render', 'inner cleanup']) sequence.length = 0 outer.show = true await invalidate(outer) assert_eq(el.innerHTML, 'inner') // inner is mounted a second time because of the above cleanup assert_deep_eq(sequence, ['outer render', 'inner mount', 'inner render']) sequence.length = 0 }) test('onMount registers multiple callbacks', () => { const { root } = setup() const sequence: string[] = [] const app = { render() { return 'app' }, } onMount(app, () => { sequence.push('mount 1') return () => sequence.push('cleanup 1') }) onMount(app, () => { sequence.push('mount 2') return () => sequence.push('cleanup 2') }) root.render(app) assert_deep_eq(sequence, ['mount 1', 'mount 2']) sequence.length = 0 root.render(null) assert_deep_eq(sequence, ['cleanup 1', 'cleanup 2']) }) test('onMount registers a fixed callback multiple times', () => { const { root } = setup() const sequence: string[] = [] function callback() { sequence.push('mount') return () => sequence.push('cleanup') } const app = { render() { return 'app' }, } onMount(app, callback) onMount(app, callback) root.render(app) assert_deep_eq(sequence, ['mount', 'mount']) sequence.length = 0 root.render(null) assert_deep_eq(sequence, ['cleanup', 'cleanup']) }) test('onMount registers callbacks outside of render', () => { const { root } = setup() const sequence: string[] = [] const app = { render() { sequence.push('render') return 'app' }, } onMount(app, () => { sequence.push('mount') return () => sequence.push('cleanup') }) assert_deep_eq(sequence, []) root.render(app) assert_deep_eq(sequence, ['mount', 'render']) sequence.length = 0 root.render(null) assert_deep_eq(sequence, ['cleanup']) }) test('onMount is called immediately on a mounted renderable', () => { const { root } = setup() const app = { render() { return 'app' }, } root.render(app) let calls = 0 onMount(app, () => { calls++ }) assert_eq(calls, 1) }) test('onUnmount deep works correctly', async () => { const { root, el } = setup() const sequence: string[] = [] const inner = { render() { sequence.push('inner render') return 'inner' }, } onUnmount(inner, () => { sequence.push('inner abort') }) const outer = { show: true, render() { sequence.push('outer render') if (!this.show) return null return inner }, } onUnmount(outer, () => { sequence.push('outer abort') }) outer.show = true root.render(outer) assert_eq(el.innerHTML, 'inner') assert_deep_eq(sequence, ['outer render', 'inner render']) sequence.length = 0 outer.show = false await invalidate(outer) assert_eq(el.innerHTML, '') assert_deep_eq(sequence, ['outer render', 'inner abort']) sequence.length = 0 outer.show = true await invalidate(outer) assert_eq(el.innerHTML, 'inner') assert_deep_eq(sequence, ['outer render', 'inner render']) sequence.length = 0 outer.show = false await invalidate(outer) assert_eq(el.innerHTML, '') assert_deep_eq(sequence, ['outer render', 'inner abort']) sequence.length = 0 }) test('onUnmount shallow works correctly', async () => { const { root, el } = setup() const sequence: string[] = [] const inner = { render() { sequence.push('inner render') return 'inner' }, } onUnmount(inner, () => { sequence.push('inner abort') }) const outer = { attached: false, show: true, render() { sequence.push('outer render') if (!this.attached) { this.attached = true onUnmount(this, () => { this.attached = false sequence.push('outer abort') }) } return html`${this.show ? inner : null}` }, } outer.show = true root.render(outer) assert_eq(el.innerHTML, 'inner') assert_deep_eq(sequence, ['outer render', 'inner render']) sequence.length = 0 outer.show = false await invalidate(outer) assert_eq(el.innerHTML, '') assert_deep_eq(sequence, ['outer render', 'inner abort']) sequence.length = 0 outer.show = true await invalidate(outer) assert_eq(el.innerHTML, 'inner') assert_deep_eq(sequence, ['outer render', 'inner render']) sequence.length = 0 outer.show = false await invalidate(outer) assert_eq(el.innerHTML, '') assert_deep_eq(sequence, ['outer render', 'inner abort']) sequence.length = 0 }) test('onUnmount works externally', async () => { const { root, el } = setup() const app = { render() { return [1, 2, 3].map(i => html`
${i}
`) }, } let unmounts = 0 onUnmount(app, () => { unmounts++ }) root.render(app) assert_eq(el.innerHTML, '
1
2
3
') assert_eq(unmounts, 0) root.render(null) assert_eq(unmounts, 1) }) test('onMount works for repeated mounts', () => { const { root } = setup() let mounted = 0 const app = { render() { return html`${mounted}` }, } onMount(app, () => { mounted++ return () => { mounted-- } }) assert_eq(mounted, 0) for (let i = 0; i < 10; i++) { root.render(app) assert_eq(mounted, 1) root.render(null) assert_eq(mounted, 0) } }) test('renderables can be rendered in multiple places at once', async () => { const { root: root1, el: el1 } = setup() const { root: root2, el: el2 } = setup() let mounted = 0 const app = { value: 'shared', render() { return this.value }, } onMount(app, () => { mounted++ return () => mounted-- }) // Render in first location root1.render(app) assert_eq(el1.innerHTML, 'shared') assert_eq(mounted, 1) // Render in second location - should NOT mount again (mount only called on first mount) root2.render(app) assert_eq(el2.innerHTML, 'shared') assert_eq(mounted, 1) // Still 1, not 2 // Update the renderable - both should update app.value = 'updated' await invalidate(app) assert_eq(el1.innerHTML, 'updated') assert_eq(el2.innerHTML, 'updated') // Remove from first location - should NOT unmount yet root1.render(null) assert_eq(mounted, 1) // Still mounted in second location assert_eq(el2.innerHTML, 'updated') // Second location still works // Remove from second location - NOW it should unmount root2.render(null) assert_eq(mounted, 0) // Now unmounted }) test('renderables can be rendered in multiple places at once with a single root', async () => { const { root, el } = setup() let mounted = 0 const thing = { value: 'shared', render() { return this.value }, } onMount(thing, () => { mounted++ return () => mounted-- }) root.render(html`${thing}${thing}`) assert_eq(mounted, 1) assert_eq(el.innerHTML, 'sharedshared') thing.value = 'updated' await invalidate(thing) assert_eq(mounted, 1) assert_eq(el.innerHTML, 'updatedupdated') root.render(null) assert_eq(mounted, 0) }) test('invalidating an unmounted renderable does nothing', async () => { const { root, el } = setup() const app1 = { render() { return 'app1' }, } const app2 = { render() { return 'app2' }, } root.render(app1) assert_eq(el.textContent, 'app1') root.render(app2) assert_eq(el.textContent, 'app2') await invalidate(app1) assert_eq(el.textContent, 'app2') }) test('onMount called on already mounted renderable executes immediately', () => { const { root } = setup() let mounted = 0 let unmounted = 0 const app = { render() { return 'app' }, } root.render(app) onMount(app, () => { mounted++ return () => { unmounted++ } }) assert_eq(mounted, 1) assert_eq(unmounted, 0) root.render(null) assert_eq(unmounted, 1) }) test('invalidating a parent does not re-render a child', async () => { const { root, el } = setup() let renders = 0 const child = { render() { renders++ return 'child' }, } const parent = { render() { return child }, } root.render(parent) assert_eq(el.innerHTML, 'child') assert_eq(renders, 1) await invalidate(parent) assert_eq(el.innerHTML, 'child') assert_eq(renders, 1) }) test('invalidating parent during child render triggers update', async () => { const { root, el } = setup() let promise: Promise const item = { render() { app.loading = true promise = invalidate(app) return 'created' }, } const app = { loading: false, render() { if (this.loading) return 'loading' return item }, } root.render(app) assert(promise!) await promise assert_eq(el.innerHTML, 'loading') }) test('invalidating grandparent during child render triggers update', async () => { const { root, el } = setup() let promise: Promise const item = { render() { app.loading = true promise = invalidate(app) return 'created' }, } const middle = { item: null as Displayable, render() { return this.item }, } const app = { loading: false, render() { if (this.loading) return 'loading' return middle }, } root.render(app) assert_eq(el.innerHTML, '') middle.item = item await invalidate(middle) assert(promise!) await promise assert_eq(el.innerHTML, 'loading') }) test('invalidate drains reinvalidation of the same renderable before resolve', async () => { const { root, el } = setup() let state = 0 let nested: Promise | undefined const app = { render() { if (state === 1) { state = 2 nested = invalidate(app) } return '' + state }, } root.render(app) assert_eq(el.innerHTML, '0') state = 1 const promise = invalidate(app) await promise assert_eq(el.innerHTML, '2') assert(nested!) await nested })