Skip to content

Commit 7a47c3b

Browse files
authored
add onMount (#34)
* add onMount * bump coverage
1 parent 3ea1601 commit 7a47c3b

File tree

7 files changed

+464
-58
lines changed

7 files changed

+464
-58
lines changed

src/html.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { CustomPartConstructor as CustomPart, Displayable, Renderable }
55
export function html(statics: TemplateStringsArray, ...dynamics: unknown[]): BoundTemplateInstance
66
export function keyed<T extends Displayable & object>(value: T, key: Key): T
77
export function invalidate(renderable: Renderable): Promise<void>
8+
export function onMount(renderable: Renderable, callback: () => void | (() => void)): void
89
export function onUnmount(renderable: Renderable, callback: () => void): void
910
export function getParentNode(renderable: Renderable): Node
1011

src/html.js

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ export const html = (statics, ...dynamics) => new BoundTemplateInstance(statics,
3333

3434
const singlePartTemplate = part => html`${part}`
3535

36+
/* v8 ignore start */
3637
/** @return {asserts value} */
3738
const assert = (value, message = 'assertion failed') => {
3839
if (!DEV) return
3940
if (!value) throw new Error(message)
4041
}
42+
/* v8 ignore stop */
4143

4244
/** @implements {SpanInstance} */
4345
class Span {
@@ -322,35 +324,47 @@ function compileTemplate(statics) {
322324
}
323325

324326
/** @type {WeakMap<object, {
327+
_mounted: boolean
325328
_invalidateQueued: Promise<void> | null
326329
_invalidate: () => void
327-
_unmountCallbacks: Set<() => void> | null
330+
_unmountCallbacks: Set<void | (() => void)> | null
328331
_parentNode: Node
329332
}>} */
330333
const controllers = new WeakMap()
334+
331335
export function invalidate(renderable) {
332336
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
335337
assert(controller, 'the renderable has not been rendered')
336-
337-
// TODO: cancel this invalidation if a higher up one comes along
338338
return (controller._invalidateQueued ??= Promise.resolve().then(() => {
339339
controller._invalidateQueued = null
340340
controller._invalidate()
341341
}))
342342
}
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+
344350
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+
}
346355

347-
controller._unmountCallbacks ??= new Set()
348-
controller._unmountCallbacks.add(callback)
356+
let cb = mountCallbacks.get(renderable)
357+
if (!cb) mountCallbacks.set(renderable, (cb = new Set()))
358+
cb.add(callback)
349359
}
360+
361+
export function onUnmount(renderable, callback) {
362+
onMount(renderable, () => callback)
363+
}
364+
350365
export function getParentNode(renderable) {
351366
const controller = controllers.get(renderable)
352367
assert(controller, 'the renderable has not been rendered')
353-
354368
return controller._parentNode
355369
}
356370

@@ -408,7 +422,7 @@ class ChildPart {
408422
#switchRenderable(next) {
409423
if (this.#renderable && this.#renderable !== next) {
410424
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?.()
412426
controllers.delete(this.#renderable)
413427
}
414428
this.#renderable = next
@@ -433,6 +447,7 @@ class ChildPart {
433447

434448
if (!controllers.has(renderable))
435449
controllers.set(renderable, {
450+
_mounted: false,
436451
_invalidateQueued: null,
437452
_invalidate: () => {
438453
DEV: assert(this.#renderable === renderable, 'could not invalidate an outdated renderable')
@@ -511,6 +526,18 @@ class ChildPart {
511526
}
512527

513528
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+
514541
if (endsWereEqual) this.#parentSpan._end = this.#span._end
515542

516543
return
@@ -541,9 +568,20 @@ class ChildPart {
541568
}
542569
}
543570

544-
if (endsWereEqual) this.#parentSpan._end = this.#span._end
545-
546571
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+
}
583+
584+
if (endsWereEqual) this.#parentSpan._end = this.#span._end
547585
}
548586

549587
detach() {

test/attributes.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ describe('attributes', () => {
1010
expect(el.querySelector('h1')).toHaveAttribute('style', 'color: red')
1111
})
1212

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+
1331
it('supports property attributes', () => {
1432
const { root, el } = setup()
1533

test/basic.test.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { html, type Displayable } from 'dhtml'
2-
import { describe, expect, it } from 'vitest'
2+
import { describe, expect, it, vi } from 'vitest'
33
import { setup } from './setup'
44

55
describe('basic', () => {
@@ -48,21 +48,6 @@ describe('basic', () => {
4848
expect(el.innerHTML).toMatchInlineSnapshot(`"<div>before</div><h1>Hello, world!</h1><div>after</div>"`)
4949
})
5050

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-
})
65-
6651
it('update identity', () => {
6752
const { root, el } = setup()
6853

@@ -126,3 +111,69 @@ describe('basic', () => {
126111
expect((el.firstChild as Text).data).toBe('abc')
127112
})
128113
})
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+
})

test/lists.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,25 @@ describe('lists', () => {
161161
root.render([2])
162162
expect(el.innerHTML).toBe('2')
163163
})
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+
})
164183
})
165184

166185
describe('list reordering', () => {
@@ -311,3 +330,10 @@ describe('list reordering', () => {
311330
expect(el.innerHTML).toBe(items.map(([, html]) => html).join(''))
312331
})
313332
})
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+
})

0 commit comments

Comments
 (0)