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

add onMount (#34)

* add onMount
* bump coverage

authored by tombl.dev and committed by

GitHub 7a47c3b5 3ea1601a

+462 -56
+1
src/html.d.ts
··· 5 5 export function html(statics: TemplateStringsArray, ...dynamics: unknown[]): BoundTemplateInstance 6 6 export function keyed<T extends Displayable & object>(value: T, key: Key): T 7 7 export function invalidate(renderable: Renderable): Promise<void> 8 + export function onMount(renderable: Renderable, callback: () => void | (() => void)): void 8 9 export function onUnmount(renderable: Renderable, callback: () => void): void 9 10 export function getParentNode(renderable: Renderable): Node 10 11
+51 -13
src/html.js
··· 33 33 34 34 const singlePartTemplate = part => html`${part}` 35 35 36 + /* v8 ignore start */ 36 37 /** @return {asserts value} */ 37 38 const assert = (value, message = 'assertion failed') => { 38 39 if (!DEV) return 39 40 if (!value) throw new Error(message) 40 41 } 42 + /* v8 ignore stop */ 41 43 42 44 /** @implements {SpanInstance} */ 43 45 class Span { ··· 322 324 } 323 325 324 326 /** @type {WeakMap<object, { 327 + _mounted: boolean 325 328 _invalidateQueued: Promise<void> | null 326 329 _invalidate: () => void 327 - _unmountCallbacks: Set<() => void> | null 330 + _unmountCallbacks: Set<void | (() => void)> | null 328 331 _parentNode: Node 329 332 }>} */ 330 333 const controllers = new WeakMap() 334 + 331 335 export function invalidate(renderable) { 332 336 const controller = controllers.get(renderable) 333 - // TODO: if no controller, check again in a microtask? 334 - // just in case the renderable was created between invalidation and rerendering 335 337 assert(controller, 'the renderable has not been rendered') 336 - 337 - // TODO: cancel this invalidation if a higher up one comes along 338 338 return (controller._invalidateQueued ??= Promise.resolve().then(() => { 339 339 controller._invalidateQueued = null 340 340 controller._invalidate() 341 341 })) 342 342 } 343 - export function onUnmount(renderable, callback) { 343 + 344 + /** @type {WeakMap<Renderable, Set<() => void | (() => void)>>} */ 345 + const mountCallbacks = new WeakMap() 346 + 347 + export function onMount(renderable, callback) { 348 + DEV: assert(isRenderable(renderable), 'expected a renderable') 349 + 344 350 const controller = controllers.get(renderable) 345 - assert(controller, 'the renderable has not been rendered') 351 + if (controller?._mounted) { 352 + ;(controller._unmountCallbacks ??= new Set()).add(callback()) 353 + return 354 + } 355 + 356 + let cb = mountCallbacks.get(renderable) 357 + if (!cb) mountCallbacks.set(renderable, (cb = new Set())) 358 + cb.add(callback) 359 + } 346 360 347 - controller._unmountCallbacks ??= new Set() 348 - controller._unmountCallbacks.add(callback) 361 + export function onUnmount(renderable, callback) { 362 + onMount(renderable, () => callback) 349 363 } 364 + 350 365 export function getParentNode(renderable) { 351 366 const controller = controllers.get(renderable) 352 367 assert(controller, 'the renderable has not been rendered') 353 - 354 368 return controller._parentNode 355 369 } 356 370 ··· 408 422 #switchRenderable(next) { 409 423 if (this.#renderable && this.#renderable !== next) { 410 424 const controller = controllers.get(this.#renderable) 411 - if (controller?._unmountCallbacks) for (const callback of controller._unmountCallbacks) callback() 425 + if (controller?._unmountCallbacks) for (const callback of controller._unmountCallbacks) callback?.() 412 426 controllers.delete(this.#renderable) 413 427 } 414 428 this.#renderable = next ··· 433 447 434 448 if (!controllers.has(renderable)) 435 449 controllers.set(renderable, { 450 + _mounted: false, 436 451 _invalidateQueued: null, 437 452 _invalidate: () => { 438 453 DEV: assert(this.#renderable === renderable, 'could not invalidate an outdated renderable') ··· 511 526 } 512 527 513 528 this.#span._end = end 529 + 530 + const controller = controllers.get(this.#renderable) 531 + if (controller) { 532 + controller._mounted = true 533 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 534 + for (const callback of mountCallbacks.get(this.#renderable) ?? []) { 535 + ;(controller._unmountCallbacks ??= new Set()).add(callback()) 536 + } 537 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 538 + mountCallbacks.delete(this.#renderable) 539 + } 540 + 514 541 if (endsWereEqual) this.#parentSpan._end = this.#span._end 515 542 516 543 return ··· 541 568 } 542 569 } 543 570 544 - if (endsWereEqual) this.#parentSpan._end = this.#span._end 571 + this.#value = value 572 + 573 + const controller = controllers.get(this.#renderable) 574 + if (controller) { 575 + controller._mounted = true 576 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 577 + for (const callback of mountCallbacks.get(this.#renderable) ?? []) { 578 + ;(controller._unmountCallbacks ??= new Set()).add(callback()) 579 + } 580 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 581 + mountCallbacks.delete(this.#renderable) 582 + } 545 583 546 - this.#value = value 584 + if (endsWereEqual) this.#parentSpan._end = this.#span._end 547 585 } 548 586 549 587 detach() {
+18
test/attributes.test.ts
··· 10 10 expect(el.querySelector('h1')).toHaveAttribute('style', 'color: red') 11 11 }) 12 12 13 + it('can toggle attributes', () => { 14 + const { root, el } = setup() 15 + 16 + let hidden: unknown = false 17 + const template = () => html`<h1 hidden=${hidden}>Hello, world!</h1>` 18 + 19 + root.render(template()) 20 + expect(el.querySelector('h1')).not.toHaveAttribute('hidden') 21 + 22 + hidden = true 23 + root.render(template()) 24 + expect(el.querySelector('h1')).toHaveAttribute('hidden') 25 + 26 + hidden = null 27 + root.render(template()) 28 + expect(el.querySelector('h1')).not.toHaveAttribute('hidden') 29 + }) 30 + 13 31 it('supports property attributes', () => { 14 32 const { root, el } = setup() 15 33
+67 -16
test/basic.test.ts
··· 1 1 import { html, type Displayable } from 'dhtml' 2 - import { describe, expect, it } from 'vitest' 2 + import { describe, expect, it, vi } from 'vitest' 3 3 import { setup } from './setup' 4 4 5 5 describe('basic', () => { ··· 46 46 47 47 root.render(html`<h1>Hello, world!</h1>`) 48 48 expect(el.innerHTML).toMatchInlineSnapshot(`"<div>before</div><h1>Hello, world!</h1><div>after</div>"`) 49 - }) 50 - 51 - it('user errors', { skip: import.meta.env.PROD }, () => { 52 - const { root, el } = setup() 53 - 54 - let thrown 55 - try { 56 - root.render(html`<button @click=${123}></button>`) 57 - } catch (error) { 58 - thrown = error as Error 59 - } 60 - 61 - expect(el.innerHTML).toBe('<button></button>') 62 - expect(thrown).toBeInstanceOf(Error) 63 - expect(thrown!.message).toMatch(/expected a function/i) 64 49 }) 65 50 66 51 it('update identity', () => { ··· 126 111 expect((el.firstChild as Text).data).toBe('abc') 127 112 }) 128 113 }) 114 + 115 + const console = { 116 + warn: vi.fn(), 117 + } 118 + vi.stubGlobal('console', console) 119 + 120 + describe('errors', () => { 121 + it('throws on non-function event handlers', { skip: import.meta.env.PROD }, () => { 122 + const { root, el } = setup() 123 + 124 + let thrown 125 + try { 126 + root.render(html`<button @click=${123}></button>`) 127 + } catch (error) { 128 + thrown = error as Error 129 + } 130 + 131 + expect(el.innerHTML).toBe('<button></button>') 132 + expect(thrown).toBeInstanceOf(Error) 133 + expect(thrown!.message).toMatch(/expected a function/i) 134 + }) 135 + 136 + it('throws cleanly', () => { 137 + const { root, el } = setup() 138 + 139 + const oops = new Error('oops') 140 + let thrown 141 + try { 142 + root.render( 143 + html`${{ 144 + render() { 145 + throw oops 146 + }, 147 + }}`, 148 + ) 149 + } catch (error) { 150 + thrown = error 151 + } 152 + expect(thrown).toBe(oops) 153 + 154 + // on an error, don't leave any visible artifacts 155 + expect(el.innerHTML).toBe('<!---->') 156 + }) 157 + 158 + it('warns on invalid part placement', () => { 159 + const { root, el } = setup() 160 + 161 + expect(console.warn).not.toHaveBeenCalled() 162 + 163 + root.render(html`<${'div'}>${'text'}</${'div'}>`) 164 + expect(el.innerHTML).toMatchInlineSnapshot(`"<dyn-$0>text</dyn-$0>"`) 165 + 166 + expect(console.warn).toHaveBeenCalledWith('dynamic value detected in static location') 167 + }) 168 + 169 + it('does not warn parts in comments', () => { 170 + const { root, el } = setup() 171 + 172 + expect(console.warn).not.toHaveBeenCalled() 173 + 174 + root.render(html`<!-- ${'text'} -->`) 175 + expect(el.innerHTML).toMatchInlineSnapshot(`"<!-- dyn-$0 -->"`) 176 + 177 + expect(console.warn).not.toHaveBeenCalled() 178 + }) 179 + })
+26
test/lists.test.ts
··· 161 161 root.render([2]) 162 162 expect(el.innerHTML).toBe('2') 163 163 }) 164 + 165 + it('can disappear', () => { 166 + const { root, el } = setup() 167 + 168 + const app = { 169 + show: true, 170 + render() { 171 + if (!this.show) return null 172 + return [1, 2, 3].map(i => html`<div>${i}</div>`) 173 + }, 174 + } 175 + 176 + root.render(app) 177 + expect(el.innerHTML).toMatchInlineSnapshot(`"<div>1</div><div>2</div><div>3</div>"`) 178 + 179 + app.show = false 180 + root.render(app) 181 + expect(el.innerHTML).toMatchInlineSnapshot(`""`) 182 + }) 164 183 }) 165 184 166 185 describe('list reordering', () => { ··· 311 330 expect(el.innerHTML).toBe(items.map(([, html]) => html).join('')) 312 331 }) 313 332 }) 333 + 334 + describe('list with keys', () => { 335 + it("can't key something twice", () => { 336 + expect(() => keyed(html``, 1)).not.toThrow() 337 + expect(() => keyed(keyed(html``, 1), 1)).toThrow() 338 + }) 339 + })
+298 -27
test/renderable.test.ts
··· 1 - import { getParentNode, html, invalidate, onUnmount, type Renderable } from 'dhtml' 1 + import { getParentNode, html, invalidate, onMount, onUnmount, type Renderable } from 'dhtml' 2 2 import { describe, expect, it, vi } from 'vitest' 3 3 import { setup } from './setup' 4 4 5 - describe('renderable', () => { 6 - it('basic', async () => { 5 + describe('renderables', () => { 6 + it('works', async () => { 7 7 const { root, el } = setup() 8 8 9 9 root.render( ··· 32 32 expect(app.i).toBe(4) 33 33 }) 34 34 35 - it('throws', () => { 35 + it('handles undefined', () => { 36 + const { root, el } = setup() 37 + 38 + root.render({ 39 + // @ts-expect-error 40 + render() {}, 41 + }) 42 + 43 + expect(el.innerHTML).toBe('') 44 + }) 45 + }) 46 + 47 + describe('onMount', () => { 48 + it('calls in the right order', () => { 36 49 const { root, el } = setup() 37 50 38 - const oops = new Error('oops') 39 - let thrown 40 - try { 41 - root.render( 42 - html`${{ 43 - render() { 44 - throw oops 45 - }, 46 - }}`, 47 - ) 48 - } catch (error) { 49 - thrown = error 51 + const sequence: string[] = [] 52 + 53 + const inner = { 54 + attached: false, 55 + render() { 56 + sequence.push('inner render') 57 + if (!this.attached) { 58 + this.attached = true 59 + onMount(this, () => { 60 + sequence.push('inner mount') 61 + return () => { 62 + sequence.push('inner cleanup') 63 + } 64 + }) 65 + } 66 + return 'inner' 67 + }, 68 + } 69 + 70 + const outer = { 71 + attached: false, 72 + show: true, 73 + render() { 74 + sequence.push('outer render') 75 + if (!this.attached) { 76 + this.attached = true 77 + onMount(this, () => { 78 + sequence.push('outer mount') 79 + return () => { 80 + sequence.push('outer cleanup') 81 + } 82 + }) 83 + } 84 + if (!this.show) return null 85 + return inner 86 + }, 87 + } 88 + 89 + outer.show = true 90 + root.render(outer) 91 + expect(el.innerHTML).toBe('inner') 92 + expect(sequence).toMatchInlineSnapshot(` 93 + [ 94 + "outer render", 95 + "inner render", 96 + "inner mount", 97 + "outer mount", 98 + ] 99 + `) 100 + sequence.length = 0 101 + 102 + outer.show = false 103 + root.render(outer) 104 + expect(el.innerHTML).toBe('') 105 + expect(sequence).toMatchInlineSnapshot(` 106 + [ 107 + "outer render", 108 + "inner cleanup", 109 + ] 110 + `) 111 + sequence.length = 0 112 + 113 + outer.show = true 114 + root.render(outer) 115 + expect(el.innerHTML).toBe('inner') 116 + expect(sequence).toMatchInlineSnapshot(` 117 + [ 118 + "outer render", 119 + "inner render", 120 + ] 121 + `) 122 + sequence.length = 0 123 + }) 124 + 125 + it('registers multiple callbacks', () => { 126 + const { root } = setup() 127 + 128 + const sequence: string[] = [] 129 + 130 + const app = { 131 + render() { 132 + onMount(this, () => { 133 + sequence.push('mount 1') 134 + return () => sequence.push('cleanup 1') 135 + }) 136 + 137 + onMount(this, () => { 138 + sequence.push('mount 2') 139 + return () => sequence.push('cleanup 2') 140 + }) 141 + 142 + return 'app' 143 + }, 144 + } 145 + 146 + root.render(app) 147 + expect(sequence).toMatchInlineSnapshot(` 148 + [ 149 + "mount 1", 150 + "mount 2", 151 + ] 152 + `) 153 + sequence.length = 0 154 + 155 + root.render(null) 156 + expect(sequence).toMatchInlineSnapshot(` 157 + [ 158 + "cleanup 1", 159 + "cleanup 2", 160 + ] 161 + `) 162 + }) 163 + 164 + it('registers a fixed callback once', () => { 165 + const { root } = setup() 166 + 167 + const sequence: string[] = [] 168 + 169 + function callback() { 170 + sequence.push('mount') 171 + return () => sequence.push('cleanup') 172 + } 173 + 174 + const app = { 175 + render() { 176 + onMount(this, callback) 177 + onMount(this, callback) 178 + return 'app' 179 + }, 180 + } 181 + 182 + root.render(app) 183 + expect(sequence).toMatchInlineSnapshot(` 184 + [ 185 + "mount", 186 + ] 187 + `) 188 + sequence.length = 0 189 + 190 + root.render(null) 191 + expect(sequence).toMatchInlineSnapshot(` 192 + [ 193 + "cleanup", 194 + ] 195 + `) 196 + }) 197 + 198 + it('registers callbacks outside of render', () => { 199 + const { root } = setup() 200 + 201 + const sequence: string[] = [] 202 + 203 + const app = { 204 + render() { 205 + sequence.push('render') 206 + return 'app' 207 + }, 208 + } 209 + 210 + onMount(app, () => { 211 + sequence.push('mount') 212 + return () => sequence.push('cleanup') 213 + }) 214 + 215 + expect(sequence).toMatchInlineSnapshot(`[]`) 216 + 217 + root.render(app) 218 + expect(sequence).toMatchInlineSnapshot(` 219 + [ 220 + "render", 221 + "mount", 222 + ] 223 + `) 224 + sequence.length = 0 225 + 226 + root.render(null) 227 + expect(sequence).toMatchInlineSnapshot(` 228 + [ 229 + "cleanup", 230 + ] 231 + `) 232 + }) 233 + 234 + it('can access the dom in callback', () => { 235 + const { root } = setup() 236 + 237 + const app = { 238 + render() { 239 + onMount(this, () => { 240 + const parent = getParentNode(this) as Element 241 + expect(parent.firstElementChild).toBeInstanceOf(HTMLParagraphElement) 242 + }) 243 + return html`<p>Hello, world!</p>` 244 + }, 50 245 } 51 - expect(thrown).toBe(oops) 52 246 53 - // on an error, don't leave any visible artifacts 54 - expect(el.innerHTML).toBe('<!---->') 247 + root.render(app) 55 248 }) 56 249 250 + it('works after render', () => { 251 + const { root } = setup() 252 + 253 + const app = { 254 + render() { 255 + return 'app' 256 + }, 257 + } 258 + 259 + root.render(app) 260 + 261 + const mounted = vi.fn() 262 + onMount(app, mounted) 263 + expect(mounted).toHaveBeenCalledOnce() 264 + }) 265 + }) 266 + 267 + describe('onUnmount', () => { 57 268 it('unmount deep', () => { 58 269 const { root, el } = setup() 59 270 ··· 94 305 outer.show = true 95 306 root.render(outer) 96 307 expect(el.innerHTML).toBe('inner') 97 - expect(sequence).toEqual(['outer render', 'inner render']) 308 + expect(sequence).toMatchInlineSnapshot(` 309 + [ 310 + "outer render", 311 + "inner render", 312 + ] 313 + `) 98 314 sequence.length = 0 99 315 100 316 outer.show = false 101 317 root.render(outer) 102 318 expect(el.innerHTML).toBe('') 103 - expect(sequence).toEqual(['outer render', 'inner abort']) 319 + expect(sequence).toMatchInlineSnapshot(` 320 + [ 321 + "outer render", 322 + "inner abort", 323 + ] 324 + `) 104 325 sequence.length = 0 105 326 106 327 outer.show = true 107 328 root.render(outer) 108 329 expect(el.innerHTML).toBe('inner') 109 - expect(sequence).toEqual(['outer render', 'inner render']) 330 + expect(sequence).toMatchInlineSnapshot(` 331 + [ 332 + "outer render", 333 + "inner render", 334 + ] 335 + `) 110 336 sequence.length = 0 111 337 112 338 outer.show = false 113 339 root.render(outer) 114 340 expect(el.innerHTML).toBe('') 115 - expect(sequence).toEqual(['outer render', 'inner abort']) 341 + expect(sequence).toMatchInlineSnapshot(` 342 + [ 343 + "outer render", 344 + "inner abort", 345 + ] 346 + `) 116 347 sequence.length = 0 117 348 }) 118 349 ··· 155 386 outer.show = true 156 387 root.render(outer) 157 388 expect(el.innerHTML).toBe('inner') 158 - expect(sequence).toEqual(['outer render', 'inner render']) 389 + expect(sequence).toMatchInlineSnapshot(` 390 + [ 391 + "outer render", 392 + "inner render", 393 + ] 394 + `) 159 395 sequence.length = 0 160 396 161 397 outer.show = false 162 398 root.render(outer) 163 399 expect(el.innerHTML).toBe('') 164 - expect(sequence).toEqual(['outer render', 'inner abort']) 400 + expect(sequence).toMatchInlineSnapshot(` 401 + [ 402 + "outer render", 403 + "inner abort", 404 + ] 405 + `) 165 406 sequence.length = 0 166 407 167 408 outer.show = true 168 409 root.render(outer) 169 410 expect(el.innerHTML).toBe('inner') 170 - expect(sequence).toEqual(['outer render', 'inner render']) 411 + expect(sequence).toMatchInlineSnapshot(` 412 + [ 413 + "outer render", 414 + "inner render", 415 + ] 416 + `) 171 417 sequence.length = 0 172 418 173 419 outer.show = false 174 420 root.render(outer) 175 421 expect(el.innerHTML).toBe('') 176 - expect(sequence).toEqual(['outer render', 'inner abort']) 422 + expect(sequence).toMatchInlineSnapshot(` 423 + [ 424 + "outer render", 425 + "inner abort", 426 + ] 427 + `) 177 428 sequence.length = 0 429 + }) 430 + 431 + it('works externally', async () => { 432 + const { root, el } = setup() 433 + 434 + const app = { 435 + render() { 436 + return [1, 2, 3].map(i => html`<div>${i}</div>`) 437 + }, 438 + } 439 + 440 + const unmounted = vi.fn() 441 + onUnmount(app, unmounted) 442 + 443 + root.render(app) 444 + expect(el.innerHTML).toMatchInlineSnapshot(`"<div>1</div><div>2</div><div>3</div>"`) 445 + expect(unmounted).not.toHaveBeenCalled() 446 + 447 + root.render(null) 448 + expect(unmounted).toHaveBeenCalledOnce() 178 449 }) 179 450 }) 180 451
+1
vitest.config.js
··· 11 11 DHTML_PROD: prod, 12 12 }, 13 13 test: { 14 + clearMocks: true, 14 15 coverage: { 15 16 enabled: ci, 16 17 reporter: ['text', 'json-summary', 'json'],