a post-component library for building user-interfaces on the web.

allow reentrant onMount handlers (#69)

authored by tombl.dev and committed by

GitHub afd19e1f 4fdf47dc

+108 -94
+3 -4
examples/kanban/package-lock.json
··· 7 7 "name": "kanban", 8 8 "dependencies": { 9 9 "@tombl/router": "npm:@jsr/tombl__router@^0.1.2", 10 - "dhtml": "file:../..", 10 + "dhtml": "file:../../dist", 11 11 "sqlocal": "^0.14.1" 12 12 }, 13 13 "devDependencies": { ··· 36 36 } 37 37 }, 38 38 "../../dist": { 39 - "name": "dhtml", 40 - "extraneous": true 39 + "name": "dhtml" 41 40 }, 42 41 "node_modules/@esbuild/aix-ppc64": { 43 42 "version": "0.25.0", ··· 798 797 } 799 798 }, 800 799 "node_modules/dhtml": { 801 - "resolved": "../..", 800 + "resolved": "../../dist", 802 801 "link": true 803 802 }, 804 803 "node_modules/esbuild": {
+1 -1
examples/kanban/package.json
··· 14 14 }, 15 15 "dependencies": { 16 16 "@tombl/router": "npm:@jsr/tombl__router@^0.1.2", 17 - "dhtml": "file:../..", 17 + "dhtml": "file:../../dist", 18 18 "sqlocal": "^0.14.1" 19 19 } 20 20 }
+15 -17
src/client/controller.ts
··· 5 5 export type Key = string | number | bigint | boolean | symbol | object | null 6 6 7 7 export interface Controller { 8 - _mount_callbacks?: Set<Cleanup> // undefined if mounted 9 - _unmount_callbacks: Set<Cleanup> 8 + _mounted: boolean 9 + _mount_callbacks: Cleanup[] 10 + _unmount_callbacks: Cleanup[] 10 11 11 12 _invalidate_queued?: Promise<void> 12 13 _invalidate?: () => void ··· 17 18 18 19 export function get_controller(renderable: Renderable): Controller { 19 20 let controller = controllers.get(renderable) 20 - if (controller) return controller 21 - 22 - controller = { 23 - _mount_callbacks: new Set(), 24 - _unmount_callbacks: new Set(), 25 - } 26 - 27 - controllers.set(renderable, controller) 21 + if (!controller) 22 + controllers.set( 23 + renderable, 24 + (controller = { 25 + _mounted: false, 26 + _mount_callbacks: [], 27 + _unmount_callbacks: [], 28 + }), 29 + ) 28 30 return controller 29 - } 30 - export function delete_controller(renderable: Renderable): void { 31 - controllers.delete(renderable) 32 31 } 33 32 34 33 const keys: WeakMap<Displayable & object, Key> = new WeakMap() ··· 44 43 45 44 export function onMount(renderable: Renderable, callback: () => Cleanup): void { 46 45 assert(is_renderable(renderable), 'expected a renderable') 47 - 48 46 const controller = get_controller(renderable) 49 - if (controller._mount_callbacks) { 50 - controller._mount_callbacks.add(callback) 47 + if (controller._mounted) { 48 + controller._unmount_callbacks.push(callback()) 51 49 } else { 52 - controller._unmount_callbacks.add(callback()) 50 + controller._mount_callbacks.push(callback) 53 51 } 54 52 } 55 53
+13 -7
src/client/parts.ts
··· 1 1 import type { Displayable, Renderable } from 'dhtml' 2 2 import { assert, is_html, is_iterable, is_renderable, single_part_template } from '../shared.ts' 3 - import { delete_controller, get_controller, get_key } from './controller.ts' 3 + import { get_controller, get_key } from './controller.ts' 4 4 import { create_root, create_root_after, type Root } from './root.ts' 5 5 import { create_span, delete_contents, extract_contents, insert_node, type Span } from './span.ts' 6 6 import type { Cleanup } from './util.ts' ··· 26 26 function switch_renderable(next: Renderable | null) { 27 27 if (current_renderable && current_renderable !== next) { 28 28 const controller = get_controller(current_renderable) 29 - controller._unmount_callbacks.forEach(callback => callback?.()) 30 - delete_controller(current_renderable) 29 + if (controller._mounted) { 30 + controller._mounted = false 31 + controller._unmount_callbacks.forEach(callback => callback?.()) 32 + } 31 33 } 32 34 current_renderable = next 33 35 } ··· 148 150 149 151 if (current_renderable) { 150 152 const controller = get_controller(current_renderable) 151 - controller._mount_callbacks?.forEach(callback => controller._unmount_callbacks.add(callback?.())) 152 - delete controller._mount_callbacks 153 + if (!controller._mounted) { 154 + controller._mounted = true 155 + controller._unmount_callbacks = controller._mount_callbacks.map(callback => callback?.()) 156 + } 153 157 } 154 158 155 159 if (ends_were_equal) parent_span._end = span._end ··· 186 190 187 191 if (current_renderable) { 188 192 const controller = get_controller(current_renderable) 189 - controller._mount_callbacks?.forEach(callback => controller._unmount_callbacks.add(callback?.())) 190 - delete controller._mount_callbacks 193 + if (!controller._mounted) { 194 + controller._mounted = true 195 + controller._unmount_callbacks = controller._mount_callbacks.map(callback => callback?.()) 196 + } 191 197 } 192 198 193 199 if (ends_were_equal) parent_span._end = span._end
+76 -65
src/client/tests/renderable.test.ts
··· 62 62 const sequence: string[] = [] 63 63 64 64 const inner = { 65 - attached: false, 66 65 render() { 67 66 sequence.push('inner render') 68 - if (!this.attached) { 69 - this.attached = true 70 - onMount(this, () => { 71 - sequence.push('inner mount') 72 - return () => { 73 - sequence.push('inner cleanup') 74 - } 75 - }) 76 - } 77 67 return 'inner' 78 68 }, 79 69 } 70 + onMount(inner, () => { 71 + sequence.push('inner mount') 72 + return () => { 73 + sequence.push('inner cleanup') 74 + } 75 + }) 80 76 81 77 const outer = { 82 - attached: false, 83 78 show: true, 84 79 render() { 85 80 sequence.push('outer render') 86 - if (!this.attached) { 87 - this.attached = true 88 - onMount(this, () => { 89 - sequence.push('outer mount') 90 - return () => { 91 - sequence.push('outer cleanup') 92 - } 93 - }) 94 - } 95 81 if (!this.show) return null 96 82 return inner 97 83 }, 98 84 } 85 + 86 + onMount(outer, () => { 87 + sequence.push('outer mount') 88 + return () => { 89 + sequence.push('outer cleanup') 90 + } 91 + }) 99 92 100 93 outer.show = true 101 94 root.render(outer) ··· 112 105 outer.show = true 113 106 root.render(outer) 114 107 assert.equal(el.innerHTML, 'inner') 115 - assert.deepStrictEqual(sequence, ['outer render', 'inner render']) 108 + // inner is mounted a second time because of the above cleanup 109 + assert.deepStrictEqual(sequence, ['outer render', 'inner render', 'inner mount']) 116 110 sequence.length = 0 117 111 }) 118 112 ··· 123 117 124 118 const app = { 125 119 render() { 126 - onMount(this, () => { 127 - sequence.push('mount 1') 128 - return () => sequence.push('cleanup 1') 129 - }) 130 - 131 - onMount(this, () => { 132 - sequence.push('mount 2') 133 - return () => sequence.push('cleanup 2') 134 - }) 135 - 136 120 return 'app' 137 121 }, 138 122 } 139 123 124 + onMount(app, () => { 125 + sequence.push('mount 1') 126 + return () => sequence.push('cleanup 1') 127 + }) 128 + 129 + onMount(app, () => { 130 + sequence.push('mount 2') 131 + return () => sequence.push('cleanup 2') 132 + }) 133 + 140 134 root.render(app) 141 135 assert.deepStrictEqual(sequence, ['mount 1', 'mount 2']) 142 136 sequence.length = 0 ··· 145 139 assert.deepStrictEqual(sequence, ['cleanup 1', 'cleanup 2']) 146 140 }) 147 141 148 - test('onMount registers a fixed callback once', () => { 142 + test('onMount registers a fixed callback multiple times', () => { 149 143 const { root } = setup() 150 144 151 145 const sequence: string[] = [] ··· 157 151 158 152 const app = { 159 153 render() { 160 - onMount(this, callback) 161 - onMount(this, callback) 162 154 return 'app' 163 155 }, 164 156 } 157 + 158 + onMount(app, callback) 159 + onMount(app, callback) 165 160 166 161 root.render(app) 167 - assert.deepStrictEqual(sequence, ['mount']) 162 + assert.deepStrictEqual(sequence, ['mount', 'mount']) 168 163 sequence.length = 0 169 164 170 165 root.render(null) 171 - assert.deepStrictEqual(sequence, ['cleanup']) 166 + assert.deepStrictEqual(sequence, ['cleanup', 'cleanup']) 172 167 }) 173 168 174 169 test('onMount registers callbacks outside of render', () => { ··· 203 198 204 199 const app = { 205 200 render() { 206 - onMount(this, () => { 207 - const parent = getParentNode(this) as Element 208 - assert(parent.firstElementChild instanceof HTMLParagraphElement) 209 - }) 210 201 return html`<p>Hello, world!</p>` 211 202 }, 212 203 } 213 204 205 + onMount(app, () => { 206 + const parent = getParentNode(app) as Element 207 + assert(parent.firstElementChild instanceof HTMLParagraphElement) 208 + }) 209 + 214 210 root.render(app) 215 211 }) 216 212 217 - test('onMount works after render', () => { 213 + test('onMount is called immediately on a mounted renderable', () => { 218 214 const { root } = setup() 219 215 220 216 const app = { ··· 236 232 const sequence: string[] = [] 237 233 238 234 const inner = { 239 - attached: false, 240 235 render() { 241 236 sequence.push('inner render') 242 - if (!this.attached) { 243 - this.attached = true 244 - onUnmount(this, () => { 245 - this.attached = false 246 - sequence.push('inner abort') 247 - }) 248 - } 249 237 return 'inner' 250 238 }, 251 239 } 252 240 241 + onUnmount(inner, () => { 242 + sequence.push('inner abort') 243 + }) 244 + 253 245 const outer = { 254 - attached: false, 255 246 show: true, 256 247 render() { 257 248 sequence.push('outer render') 258 - if (!this.attached) { 259 - this.attached = true 260 - onUnmount(this, () => { 261 - this.attached = false 262 - sequence.push('outer abort') 263 - }) 264 - } 265 249 if (!this.show) return null 266 250 return inner 267 251 }, 268 252 } 253 + 254 + onUnmount(outer, () => { 255 + sequence.push('outer abort') 256 + }) 269 257 270 258 outer.show = true 271 259 root.render(outer) ··· 298 286 const sequence: string[] = [] 299 287 300 288 const inner = { 301 - attached: false, 302 289 render() { 303 290 sequence.push('inner render') 304 - if (!this.attached) { 305 - this.attached = true 306 - onUnmount(this, () => { 307 - this.attached = false 308 - sequence.push('inner abort') 309 - }) 310 - } 311 291 return 'inner' 312 292 }, 313 293 } 294 + 295 + onUnmount(inner, () => { 296 + sequence.push('inner abort') 297 + }) 314 298 315 299 const outer = { 316 300 attached: false, ··· 371 355 372 356 root.render(null) 373 357 assert.equal(unmounted.mock.calls.length, 1) 358 + }) 359 + 360 + test('onMount works for repeated mounts', () => { 361 + const { root } = setup() 362 + let mounted: boolean | null = null 363 + 364 + const app = { 365 + render() { 366 + return html`${mounted}` 367 + }, 368 + } 369 + onMount(app, () => { 370 + mounted = true 371 + return () => { 372 + mounted = false 373 + } 374 + }) 375 + 376 + assert.equal(mounted, null) 377 + 378 + for (let i = 0; i < 10; i++) { 379 + root.render(app) 380 + assert.equal(mounted, true) 381 + 382 + root.render(null) 383 + assert.equal(mounted, false) 384 + } 374 385 }) 375 386 376 387 test('getParentNode works externally', () => {