@wippy-fe/webcomponent-core 0.0.30 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/base-element.host.test.ts +137 -1
- package/src/base-element.ts +121 -2
- package/src/index.ts +9 -0
- package/src/reactive-adapter.test.ts +290 -0
- package/src/reactive-adapter.ts +220 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wippy-fe/webcomponent-core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Framework-agnostic base class for Wippy web components
|
|
3
|
+
"version": "0.0.31",
|
|
4
|
+
"description": "Framework-agnostic base class for Wippy web components — shadow DOM, CSS loading, schema-driven prop parsing, and lifecycle management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
],
|
|
12
12
|
"peerDependencies": {
|
|
13
13
|
"@iconify/vue": "^5.0.0",
|
|
14
|
-
"@wippy-fe/proxy": "0.0.
|
|
15
|
-
"@wippy-fe/shared": "0.0.
|
|
14
|
+
"@wippy-fe/proxy": "0.0.31",
|
|
15
|
+
"@wippy-fe/shared": "0.0.31"
|
|
16
16
|
},
|
|
17
17
|
"license": "UNLICENSED"
|
|
18
18
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the managed-layout shell's content resolvers. And the cleanup path
|
|
6
6
|
* in `disconnectedCallback` that drops stale wires on detach.
|
|
7
7
|
*/
|
|
8
|
-
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
9
9
|
import { WippyElement } from './base-element.ts'
|
|
10
10
|
|
|
11
11
|
// `ElementInternals.states` shim lives in `vitest.setup.ts` (gen-2-chat
|
|
@@ -77,6 +77,142 @@ describe('wippyElement.disconnectedCallback wire cleanup', () => {
|
|
|
77
77
|
})
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
describe('wippyElement.reactive — opt-in reactive adapter', () => {
|
|
81
|
+
// Element with a `count` schema prop so attribute changes are observable.
|
|
82
|
+
interface ReactiveProps { count: number }
|
|
83
|
+
interface ReactiveEvents { tick: { n: number } }
|
|
84
|
+
class ReactiveEl extends WippyElement<ReactiveProps, ReactiveEvents> {
|
|
85
|
+
static get wippyConfig() {
|
|
86
|
+
return {
|
|
87
|
+
propsSchema: { properties: { count: { type: 'integer' as const } } },
|
|
88
|
+
hostCssKeys: [],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Test-only handle for assertions inside lifecycle hooks. */
|
|
93
|
+
public observedHook: { props: ReactiveProps, errors: string[] } | null = null
|
|
94
|
+
/** True iff the adapter has been lazy-allocated. */
|
|
95
|
+
public get adapterAllocated(): boolean {
|
|
96
|
+
return (this as unknown as { _reactive: unknown })._reactive !== null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
protected onMount(): void {}
|
|
100
|
+
protected onUnmount(): void {}
|
|
101
|
+
|
|
102
|
+
protected onPropsChanged(props: ReactiveProps, errors: string[]): void {
|
|
103
|
+
this.observedHook = { props, errors }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const reactiveTag = `wippy-reactive-${Math.random().toString(36).slice(2)}`
|
|
107
|
+
customElements.define(reactiveTag, ReactiveEl)
|
|
108
|
+
|
|
109
|
+
it('does NOT allocate the adapter when never accessed', () => {
|
|
110
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
111
|
+
el.setAttribute('count', '1')
|
|
112
|
+
document.body.appendChild(el)
|
|
113
|
+
el.setAttribute('count', '2')
|
|
114
|
+
expect(el.adapterAllocated).toBe(false)
|
|
115
|
+
expect(el.observedHook?.props.count).toBe(2)
|
|
116
|
+
el.remove()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('lazy-allocates on first `this.reactive` access and seeds from current attributes', () => {
|
|
120
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
121
|
+
el.setAttribute('count', '7')
|
|
122
|
+
document.body.appendChild(el)
|
|
123
|
+
expect(el.adapterAllocated).toBe(false)
|
|
124
|
+
|
|
125
|
+
const reactive = (el as unknown as { reactive: ReturnType<ReactiveEl['onPropsChanged']> extends void ? any : never }).reactive
|
|
126
|
+
expect(el.adapterAllocated).toBe(true)
|
|
127
|
+
expect((reactive as any).props.value).toEqual({ count: 7 })
|
|
128
|
+
el.remove()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('fires subscribers on attribute change after allocation', () => {
|
|
132
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
133
|
+
el.setAttribute('count', '1')
|
|
134
|
+
document.body.appendChild(el)
|
|
135
|
+
|
|
136
|
+
const reactive = (el as any).reactive
|
|
137
|
+
const listener = vi.fn()
|
|
138
|
+
reactive.props.subscribe(listener)
|
|
139
|
+
|
|
140
|
+
el.setAttribute('count', '2')
|
|
141
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
142
|
+
expect(listener).toHaveBeenCalledWith({ count: 2 }, [])
|
|
143
|
+
|
|
144
|
+
el.setAttribute('count', '3')
|
|
145
|
+
expect(listener).toHaveBeenCalledTimes(2)
|
|
146
|
+
el.remove()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('events.emit dispatches a bubbling/composed CustomEvent on the element', () => {
|
|
150
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
151
|
+
document.body.appendChild(el)
|
|
152
|
+
|
|
153
|
+
const reactive = (el as any).reactive
|
|
154
|
+
const events: Event[] = []
|
|
155
|
+
el.addEventListener('tick', (e) => {
|
|
156
|
+
events.push(e)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
reactive.events.emit('tick', { n: 99 })
|
|
160
|
+
expect(events).toHaveLength(1)
|
|
161
|
+
const ev = events[0] as CustomEvent<{ n: number }>
|
|
162
|
+
expect(ev.detail).toEqual({ n: 99 })
|
|
163
|
+
expect(ev.bubbles).toBe(true)
|
|
164
|
+
expect(ev.composed).toBe(true)
|
|
165
|
+
el.remove()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('disconnectedCallback disposes the adapter; subscribers stop firing', () => {
|
|
169
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
170
|
+
document.body.appendChild(el)
|
|
171
|
+
|
|
172
|
+
const reactive = (el as any).reactive
|
|
173
|
+
const listener = vi.fn()
|
|
174
|
+
reactive.props.subscribe(listener)
|
|
175
|
+
|
|
176
|
+
el.remove()
|
|
177
|
+
expect(el.adapterAllocated).toBe(false)
|
|
178
|
+
|
|
179
|
+
// Re-append with a new attribute; the OLD subscriber must not fire.
|
|
180
|
+
el.setAttribute('count', '50')
|
|
181
|
+
document.body.appendChild(el)
|
|
182
|
+
el.setAttribute('count', '51')
|
|
183
|
+
expect(listener).not.toHaveBeenCalled()
|
|
184
|
+
el.remove()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('after reconnect, a fresh adapter is allocated on next .reactive access', () => {
|
|
188
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
189
|
+
document.body.appendChild(el)
|
|
190
|
+
|
|
191
|
+
const first = (el as any).reactive
|
|
192
|
+
el.remove()
|
|
193
|
+
document.body.appendChild(el)
|
|
194
|
+
|
|
195
|
+
const second = (el as any).reactive
|
|
196
|
+
expect(second).not.toBe(first)
|
|
197
|
+
el.remove()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('legacy onPropsChanged override still fires alongside reactive', () => {
|
|
201
|
+
const el = document.createElement(reactiveTag) as ReactiveEl
|
|
202
|
+
el.setAttribute('count', '1')
|
|
203
|
+
document.body.appendChild(el)
|
|
204
|
+
// Force-allocate adapter
|
|
205
|
+
const reactive = (el as any).reactive
|
|
206
|
+
const listener = vi.fn()
|
|
207
|
+
reactive.props.subscribe(listener)
|
|
208
|
+
|
|
209
|
+
el.setAttribute('count', '2')
|
|
210
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
211
|
+
expect(el.observedHook?.props.count).toBe(2)
|
|
212
|
+
el.remove()
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
80
216
|
describe('wippyElement reconnect — no shadow-child accumulation', () => {
|
|
81
217
|
// R3 regression: previously `connectedCallback` re-ran the full mount
|
|
82
218
|
// path (CSS injection + container append) on every reconnect. The
|
package/src/base-element.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { ReactiveAdapter } from './reactive-adapter.ts'
|
|
1
2
|
import type { WippyElementConfig } from './types.ts'
|
|
2
3
|
import { addCollection } from '@iconify/vue'
|
|
3
4
|
import { addIcons, define } from '@wippy-fe/proxy'
|
|
4
5
|
import { injectInlineCss, loadHostCss } from './css-loader.ts'
|
|
5
6
|
import { getWippyHost } from './host-scope.ts'
|
|
6
7
|
import { parseProps } from './prop-parser.ts'
|
|
8
|
+
import { ReactiveAdapterImpl } from './reactive-adapter.ts'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Abstract base class for Wippy web components.
|
|
@@ -36,13 +38,42 @@ import { parseProps } from './prop-parser.ts'
|
|
|
36
38
|
* Note: `disconnectedCallback` does NOT clear the shadow root or the
|
|
37
39
|
* stored container — they're reused on reconnect.
|
|
38
40
|
*/
|
|
39
|
-
export abstract class WippyElement<
|
|
41
|
+
export abstract class WippyElement<
|
|
42
|
+
Props = Record<string, unknown>,
|
|
43
|
+
Events = Record<string, unknown>,
|
|
44
|
+
> extends HTMLElement {
|
|
40
45
|
private _internals!: ElementInternals
|
|
41
46
|
private _contentObserver: MutationObserver | null = null
|
|
42
47
|
/** True after the first successful connectedCallback completes. */
|
|
43
48
|
private _initialized: boolean = false
|
|
44
49
|
/** Container div created on first connect; reused on subsequent connects. */
|
|
45
50
|
private _container: HTMLElement | null = null
|
|
51
|
+
/**
|
|
52
|
+
* Opt-in reactive adapter. Lazy-allocated on first `this.reactive`
|
|
53
|
+
* access, disposed on `disconnectedCallback`, re-allocated on next
|
|
54
|
+
* access after reconnect. Subclasses that never touch `this.reactive`
|
|
55
|
+
* pay zero allocation cost — `notifyProps`/`notifyContent` short-circuit
|
|
56
|
+
* on the null guard.
|
|
57
|
+
*/
|
|
58
|
+
private _reactive: ReactiveAdapterImpl<Props, Events> | null = null
|
|
59
|
+
/**
|
|
60
|
+
* Cached results of the most recent `parseProps` + `validateProps`
|
|
61
|
+
* pass. Set by `connectedCallback` and `attributeChangedCallback`.
|
|
62
|
+
* Read by `get reactive()` to avoid re-parsing on lazy allocation —
|
|
63
|
+
* means `validateProps` only ever runs once per attribute change,
|
|
64
|
+
* even for consumers that touch `this.reactive` inside `onMount`.
|
|
65
|
+
*/
|
|
66
|
+
private _lastProps: Props | null = null
|
|
67
|
+
private _lastErrors: readonly string[] = []
|
|
68
|
+
private _lastContent: string | null = null
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Phantom field to retain the `Events` type parameter so it shows up in
|
|
72
|
+
* type errors / `Reflect.get` shape. Adapter typing reads it indirectly
|
|
73
|
+
* via the generic.
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
declare readonly _events: Events
|
|
46
77
|
|
|
47
78
|
/**
|
|
48
79
|
* Override to provide the component's configuration.
|
|
@@ -101,6 +132,65 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
101
132
|
}))
|
|
102
133
|
}
|
|
103
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Opt-in reactive adapter — framework-agnostic. Subscribe to prop
|
|
137
|
+
* changes, content changes, or emit typed events from a non-Vue
|
|
138
|
+
* consumer without re-rolling reactivity.
|
|
139
|
+
*
|
|
140
|
+
* ```ts
|
|
141
|
+
* class MyEl extends WippyElement<{ count: number }, { tick: { n: number } }> {
|
|
142
|
+
* protected onMount() {
|
|
143
|
+
* const ctrl = new AbortController()
|
|
144
|
+
* this.reactive.props.subscribe(({ count }) => {
|
|
145
|
+
* this.shadowRoot!.querySelector('.n')!.textContent = String(count)
|
|
146
|
+
* }, { signal: ctrl.signal, immediate: true })
|
|
147
|
+
* }
|
|
148
|
+
* tick(n: number) { this.reactive.events.emit('tick', { n }) }
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* Allocation cost is zero unless this getter is touched. Disposed on
|
|
153
|
+
* `disconnectedCallback`; a fresh adapter is allocated on the next
|
|
154
|
+
* access after reconnect.
|
|
155
|
+
*/
|
|
156
|
+
protected get reactive(): ReactiveAdapter<Props, Events> {
|
|
157
|
+
if (!this._reactive) {
|
|
158
|
+
const config = (this.constructor as typeof WippyElement).wippyConfig
|
|
159
|
+
const hasContent = !!config.contentTemplate
|
|
160
|
+
// Reuse the cached parse result from the most recent
|
|
161
|
+
// `connectedCallback` / `attributeChangedCallback` whenever we
|
|
162
|
+
// have one; otherwise fall back to a fresh parse (this happens
|
|
163
|
+
// when `reactive` is touched before the element is ever
|
|
164
|
+
// connected — rare but legal).
|
|
165
|
+
let props: Props
|
|
166
|
+
let errors: readonly string[]
|
|
167
|
+
if (this._lastProps !== null) {
|
|
168
|
+
props = this._lastProps
|
|
169
|
+
errors = this._lastErrors
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const parsed = parseProps(this, config.propsSchema)
|
|
173
|
+
if (config.validateProps) {
|
|
174
|
+
parsed.errors.push(...config.validateProps(parsed.props))
|
|
175
|
+
}
|
|
176
|
+
props = parsed.props as Props
|
|
177
|
+
errors = parsed.errors
|
|
178
|
+
this._lastProps = props
|
|
179
|
+
this._lastErrors = errors
|
|
180
|
+
}
|
|
181
|
+
const content = hasContent
|
|
182
|
+
? (this._lastContent ?? this._extractContent(config.contentTemplate!))
|
|
183
|
+
: null
|
|
184
|
+
if (hasContent && this._lastContent === null)
|
|
185
|
+
this._lastContent = content
|
|
186
|
+
this._reactive = new ReactiveAdapterImpl<Props, Events>(
|
|
187
|
+
{ props, errors, content, hasContent },
|
|
188
|
+
this.emitEvent.bind(this),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
return this._reactive
|
|
192
|
+
}
|
|
193
|
+
|
|
104
194
|
// ── Lifecycle ──────────────────────────────────────────────
|
|
105
195
|
|
|
106
196
|
connectedCallback(): void {
|
|
@@ -154,13 +244,23 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
154
244
|
errors.push(...config.validateProps(props))
|
|
155
245
|
}
|
|
156
246
|
const typedProps = props as Props
|
|
247
|
+
// Cache so a lazy `get reactive()` call from inside `onMount`
|
|
248
|
+
// doesn't re-parse / re-validate.
|
|
249
|
+
this._lastProps = typedProps
|
|
250
|
+
this._lastErrors = errors
|
|
157
251
|
|
|
158
252
|
// 7b. Extract children content (if contentTemplate configured)
|
|
159
253
|
let initialContent: string | null = null
|
|
160
254
|
if (config.contentTemplate) {
|
|
161
255
|
initialContent = this._extractContent(config.contentTemplate)
|
|
256
|
+
this._lastContent = initialContent
|
|
162
257
|
this._contentObserver = new MutationObserver(() => {
|
|
163
258
|
const content = this._extractContent(config.contentTemplate!)
|
|
259
|
+
this._lastContent = content
|
|
260
|
+
// Fan out to the reactive adapter first so subclass overrides of
|
|
261
|
+
// `onContentChanged` that read `this.reactive.content.value` see
|
|
262
|
+
// the new value. No-op when the adapter hasn't been allocated.
|
|
263
|
+
this._reactive?.notifyContent(content)
|
|
164
264
|
this.onContentChanged(content)
|
|
165
265
|
})
|
|
166
266
|
this._contentObserver.observe(this, {
|
|
@@ -207,6 +307,17 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
207
307
|
this.onUnmount()
|
|
208
308
|
this.emitEvent('unload')
|
|
209
309
|
this._internals.states.clear()
|
|
310
|
+
// Drop the reactive adapter so subscribers from this attachment are
|
|
311
|
+
// cleared and a fresh adapter is allocated on the next `this.reactive`
|
|
312
|
+
// access (i.e. after reconnect).
|
|
313
|
+
this._reactive?.dispose()
|
|
314
|
+
this._reactive = null
|
|
315
|
+
// Clear cached parse state — `connectedCallback` re-populates on
|
|
316
|
+
// reconnect; before then, a lazy `get reactive()` access should
|
|
317
|
+
// re-parse from current attributes (rare path).
|
|
318
|
+
this._lastProps = null
|
|
319
|
+
this._lastErrors = []
|
|
320
|
+
this._lastContent = null
|
|
210
321
|
// Drop panel-context wires so a detached element held by a
|
|
211
322
|
// consumer (or re-inserted into a different panel) doesn't carry
|
|
212
323
|
// stale wires that would silently misattribute. The resolver
|
|
@@ -223,7 +334,15 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
223
334
|
if (config.validateProps) {
|
|
224
335
|
errors.push(...config.validateProps(props))
|
|
225
336
|
}
|
|
226
|
-
|
|
337
|
+
const typedProps = props as Props
|
|
338
|
+
this._lastProps = typedProps
|
|
339
|
+
this._lastErrors = errors
|
|
340
|
+
// Fan out to the reactive adapter BEFORE the legacy hook so subclasses
|
|
341
|
+
// that override `onPropsChanged` AND read `this.reactive.props.value`
|
|
342
|
+
// inside it see consistent state. No-op when the adapter hasn't been
|
|
343
|
+
// lazy-allocated.
|
|
344
|
+
this._reactive?.notifyProps(typedProps, errors)
|
|
345
|
+
this.onPropsChanged(typedProps, errors)
|
|
227
346
|
}
|
|
228
347
|
|
|
229
348
|
// ── Hooks ──────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,15 @@ export type { BroadcastEnvelope, LayoutBusBound } from './host-bus.ts'
|
|
|
5
5
|
export { getWippyHost } from './host-scope.ts'
|
|
6
6
|
export { attrToCamel, parseProps } from './prop-parser.ts'
|
|
7
7
|
export type { ParseResult } from './prop-parser.ts'
|
|
8
|
+
export type {
|
|
9
|
+
ContentListener,
|
|
10
|
+
ContentSubscription,
|
|
11
|
+
EventEmitterHandle,
|
|
12
|
+
PropsListener,
|
|
13
|
+
PropsSubscription,
|
|
14
|
+
ReactiveAdapter,
|
|
15
|
+
SubscribeOptions,
|
|
16
|
+
} from './reactive-adapter.ts'
|
|
8
17
|
export type {
|
|
9
18
|
HostCssKey,
|
|
10
19
|
WippyElementConfig,
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { ReactiveAdapterImpl } from './reactive-adapter.ts'
|
|
3
|
+
|
|
4
|
+
// ─────────────────────────────────────────────────────────────
|
|
5
|
+
// Helpers
|
|
6
|
+
// ─────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface TestProps {
|
|
9
|
+
count: number
|
|
10
|
+
label: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TestEvents {
|
|
14
|
+
tick: { n: number }
|
|
15
|
+
reset: { reason: string }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeAdapter(initial?: {
|
|
19
|
+
props?: TestProps
|
|
20
|
+
errors?: string[]
|
|
21
|
+
content?: string | null
|
|
22
|
+
hasContent?: boolean
|
|
23
|
+
}) {
|
|
24
|
+
const emit = vi.fn()
|
|
25
|
+
const adapter = new ReactiveAdapterImpl<TestProps, TestEvents>(
|
|
26
|
+
{
|
|
27
|
+
props: initial?.props ?? { count: 0, label: 'init' },
|
|
28
|
+
errors: initial?.errors ?? [],
|
|
29
|
+
content: initial?.content ?? null,
|
|
30
|
+
hasContent: initial?.hasContent ?? false,
|
|
31
|
+
},
|
|
32
|
+
emit,
|
|
33
|
+
)
|
|
34
|
+
return { adapter, emit }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────
|
|
42
|
+
// props subscription
|
|
43
|
+
// ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe('reactiveAdapter — props subscription', () => {
|
|
46
|
+
it('exposes the initial props via `.value` and `.errors`', () => {
|
|
47
|
+
const { adapter } = makeAdapter({
|
|
48
|
+
props: { count: 5, label: 'hello' },
|
|
49
|
+
errors: ['oops'],
|
|
50
|
+
})
|
|
51
|
+
expect(adapter.props.value).toEqual({ count: 5, label: 'hello' })
|
|
52
|
+
expect(adapter.props.errors).toEqual(['oops'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('fires listeners on notifyProps with new values', () => {
|
|
56
|
+
const { adapter } = makeAdapter()
|
|
57
|
+
const listener = vi.fn()
|
|
58
|
+
adapter.props.subscribe(listener)
|
|
59
|
+
|
|
60
|
+
adapter.notifyProps({ count: 1, label: 'one' }, ['e'])
|
|
61
|
+
|
|
62
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(listener).toHaveBeenCalledWith({ count: 1, label: 'one' }, ['e'])
|
|
64
|
+
expect(adapter.props.value).toEqual({ count: 1, label: 'one' })
|
|
65
|
+
expect(adapter.props.errors).toEqual(['e'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returned unsubscribe function stops further callbacks', () => {
|
|
69
|
+
const { adapter } = makeAdapter()
|
|
70
|
+
const listener = vi.fn()
|
|
71
|
+
const unsub = adapter.props.subscribe(listener)
|
|
72
|
+
|
|
73
|
+
adapter.notifyProps({ count: 1, label: 'a' }, [])
|
|
74
|
+
unsub()
|
|
75
|
+
adapter.notifyProps({ count: 2, label: 'b' }, [])
|
|
76
|
+
|
|
77
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('abortSignal.abort() cancels the subscription', () => {
|
|
81
|
+
const { adapter } = makeAdapter()
|
|
82
|
+
const ctrl = new AbortController()
|
|
83
|
+
const listener = vi.fn()
|
|
84
|
+
adapter.props.subscribe(listener, { signal: ctrl.signal })
|
|
85
|
+
|
|
86
|
+
adapter.notifyProps({ count: 1, label: 'a' }, [])
|
|
87
|
+
ctrl.abort()
|
|
88
|
+
adapter.notifyProps({ count: 2, label: 'b' }, [])
|
|
89
|
+
|
|
90
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does NOT subscribe when the signal is already aborted', () => {
|
|
94
|
+
const { adapter } = makeAdapter()
|
|
95
|
+
const ctrl = new AbortController()
|
|
96
|
+
ctrl.abort()
|
|
97
|
+
const listener = vi.fn()
|
|
98
|
+
adapter.props.subscribe(listener, { signal: ctrl.signal })
|
|
99
|
+
|
|
100
|
+
adapter.notifyProps({ count: 1, label: 'a' }, [])
|
|
101
|
+
|
|
102
|
+
expect(listener).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('options.immediate fires the listener synchronously with current state', () => {
|
|
106
|
+
const { adapter } = makeAdapter({
|
|
107
|
+
props: { count: 7, label: 'now' },
|
|
108
|
+
errors: ['warn'],
|
|
109
|
+
})
|
|
110
|
+
const listener = vi.fn()
|
|
111
|
+
adapter.props.subscribe(listener, { immediate: true })
|
|
112
|
+
|
|
113
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
114
|
+
expect(listener).toHaveBeenCalledWith({ count: 7, label: 'now' }, ['warn'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('multiple subscribers all receive updates; unsubscribing one does not affect others', () => {
|
|
118
|
+
const { adapter } = makeAdapter()
|
|
119
|
+
const a = vi.fn()
|
|
120
|
+
const b = vi.fn()
|
|
121
|
+
const c = vi.fn()
|
|
122
|
+
adapter.props.subscribe(a)
|
|
123
|
+
const unsubB = adapter.props.subscribe(b)
|
|
124
|
+
adapter.props.subscribe(c)
|
|
125
|
+
|
|
126
|
+
adapter.notifyProps({ count: 1, label: 'x' }, [])
|
|
127
|
+
unsubB()
|
|
128
|
+
adapter.notifyProps({ count: 2, label: 'y' }, [])
|
|
129
|
+
|
|
130
|
+
expect(a).toHaveBeenCalledTimes(2)
|
|
131
|
+
expect(b).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(c).toHaveBeenCalledTimes(2)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────────────────────────────────────
|
|
137
|
+
// events emitter
|
|
138
|
+
// ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
describe('reactiveAdapter — AbortSignal hygiene', () => {
|
|
141
|
+
it('manual unsub() removes the abort listener from the signal (no leak)', () => {
|
|
142
|
+
const { adapter } = makeAdapter()
|
|
143
|
+
const ctrl = new AbortController()
|
|
144
|
+
const removeSpy = vi.spyOn(ctrl.signal, 'removeEventListener')
|
|
145
|
+
const listener = vi.fn()
|
|
146
|
+
const unsub = adapter.props.subscribe(listener, { signal: ctrl.signal })
|
|
147
|
+
|
|
148
|
+
unsub()
|
|
149
|
+
|
|
150
|
+
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('does not double-fire when abort fires after manual unsub', () => {
|
|
154
|
+
const { adapter } = makeAdapter()
|
|
155
|
+
const ctrl = new AbortController()
|
|
156
|
+
const listener = vi.fn()
|
|
157
|
+
const unsub = adapter.props.subscribe(listener, { signal: ctrl.signal })
|
|
158
|
+
|
|
159
|
+
unsub()
|
|
160
|
+
ctrl.abort() // would re-trigger unsub if abort listener weren't removed
|
|
161
|
+
|
|
162
|
+
// notifyProps should still find no listeners
|
|
163
|
+
adapter.notifyProps({ count: 1, label: 'x' }, [])
|
|
164
|
+
expect(listener).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('reactiveAdapter — re-entrant notify during immediate fire', () => {
|
|
169
|
+
it('captures synchronous re-entrant notifyProps from inside immediate callback', () => {
|
|
170
|
+
const { adapter } = makeAdapter()
|
|
171
|
+
const seen: Array<{ count: number, label: string }> = []
|
|
172
|
+
let triggeredOnce = false
|
|
173
|
+
adapter.props.subscribe((props) => {
|
|
174
|
+
seen.push(props)
|
|
175
|
+
// Re-enter notifyProps from inside the listener (simulating a
|
|
176
|
+
// listener that synchronously triggers an attribute change).
|
|
177
|
+
if (!triggeredOnce) {
|
|
178
|
+
triggeredOnce = true
|
|
179
|
+
adapter.notifyProps({ count: 99, label: 'after-immediate' }, [])
|
|
180
|
+
}
|
|
181
|
+
}, { immediate: true })
|
|
182
|
+
|
|
183
|
+
// Expected: 1st call = immediate fire with initial state,
|
|
184
|
+
// 2nd call = synchronous notifyProps from inside listener.
|
|
185
|
+
// Without the order-swap fix, the 2nd call would be lost.
|
|
186
|
+
expect(seen).toEqual([
|
|
187
|
+
{ count: 0, label: 'init' },
|
|
188
|
+
{ count: 99, label: 'after-immediate' },
|
|
189
|
+
])
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('reactiveAdapter — events emitter', () => {
|
|
194
|
+
it('emit delegates to the provided DOM emit function', () => {
|
|
195
|
+
const { adapter, emit } = makeAdapter()
|
|
196
|
+
adapter.events.emit('tick', { n: 42 })
|
|
197
|
+
expect(emit).toHaveBeenCalledWith('tick', { n: 42 })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('emit becomes a no-op after dispose', () => {
|
|
201
|
+
const { adapter, emit } = makeAdapter()
|
|
202
|
+
adapter.dispose()
|
|
203
|
+
adapter.events.emit('tick', { n: 1 })
|
|
204
|
+
expect(emit).not.toHaveBeenCalled()
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// ─────────────────────────────────────────────────────────────
|
|
209
|
+
// content subscription
|
|
210
|
+
// ─────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe('reactiveAdapter — content', () => {
|
|
213
|
+
it('content is null when hasContent is false', () => {
|
|
214
|
+
const { adapter } = makeAdapter({ hasContent: false })
|
|
215
|
+
expect(adapter.content).toBeNull()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('content exposes value + subscribe when hasContent is true', () => {
|
|
219
|
+
const { adapter } = makeAdapter({
|
|
220
|
+
hasContent: true,
|
|
221
|
+
content: 'initial-text',
|
|
222
|
+
})
|
|
223
|
+
expect(adapter.content?.value).toBe('initial-text')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('fires content listeners on notifyContent', () => {
|
|
227
|
+
const { adapter } = makeAdapter({ hasContent: true, content: 'a' })
|
|
228
|
+
const listener = vi.fn()
|
|
229
|
+
adapter.content!.subscribe(listener)
|
|
230
|
+
|
|
231
|
+
adapter.notifyContent('b')
|
|
232
|
+
|
|
233
|
+
expect(listener).toHaveBeenCalledWith('b')
|
|
234
|
+
expect(adapter.content!.value).toBe('b')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('immediate fires for content too', () => {
|
|
238
|
+
const { adapter } = makeAdapter({ hasContent: true, content: 'preset' })
|
|
239
|
+
const listener = vi.fn()
|
|
240
|
+
adapter.content!.subscribe(listener, { immediate: true })
|
|
241
|
+
expect(listener).toHaveBeenCalledWith('preset')
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ─────────────────────────────────────────────────────────────
|
|
246
|
+
// dispose
|
|
247
|
+
// ─────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
describe('reactiveAdapter — dispose', () => {
|
|
250
|
+
it('notifyProps becomes a no-op after dispose', () => {
|
|
251
|
+
const { adapter } = makeAdapter()
|
|
252
|
+
const listener = vi.fn()
|
|
253
|
+
adapter.props.subscribe(listener)
|
|
254
|
+
|
|
255
|
+
adapter.dispose()
|
|
256
|
+
adapter.notifyProps({ count: 99, label: 'after' }, [])
|
|
257
|
+
|
|
258
|
+
expect(listener).not.toHaveBeenCalled()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('notifyContent becomes a no-op after dispose', () => {
|
|
262
|
+
const { adapter } = makeAdapter({ hasContent: true })
|
|
263
|
+
const listener = vi.fn()
|
|
264
|
+
adapter.content!.subscribe(listener)
|
|
265
|
+
|
|
266
|
+
adapter.dispose()
|
|
267
|
+
adapter.notifyContent('after')
|
|
268
|
+
|
|
269
|
+
expect(listener).not.toHaveBeenCalled()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('subscribe after dispose returns a no-op unsub and never fires', () => {
|
|
273
|
+
const { adapter } = makeAdapter()
|
|
274
|
+
adapter.dispose()
|
|
275
|
+
const listener = vi.fn()
|
|
276
|
+
const unsub = adapter.props.subscribe(listener)
|
|
277
|
+
|
|
278
|
+
adapter.notifyProps({ count: 1, label: 'a' }, [])
|
|
279
|
+
expect(listener).not.toHaveBeenCalled()
|
|
280
|
+
expect(() => unsub()).not.toThrow()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('dispose is idempotent', () => {
|
|
284
|
+
const { adapter } = makeAdapter()
|
|
285
|
+
expect(() => {
|
|
286
|
+
adapter.dispose()
|
|
287
|
+
adapter.dispose()
|
|
288
|
+
}).not.toThrow()
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in reactive adapter for `WippyElement`.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic primitive that lets non-Vue consumers subscribe to
|
|
5
|
+
* prop / content updates and emit typed events on a web component without
|
|
6
|
+
* pulling Vue (or any other reactivity library) into the core bundle.
|
|
7
|
+
*
|
|
8
|
+
* Access via `this.reactive` on a `WippyElement` subclass — the getter is
|
|
9
|
+
* lazy, so subclasses that never touch it pay zero allocation cost. See
|
|
10
|
+
* `base-element.ts:get reactive` for the bridge into the lifecycle.
|
|
11
|
+
*
|
|
12
|
+
* The `notifyProps` / `notifyContent` methods are package-visible: the
|
|
13
|
+
* base element calls them from `attributeChangedCallback` and the content
|
|
14
|
+
* MutationObserver callback respectively. They are NOT part of the public
|
|
15
|
+
* API consumers should reach for.
|
|
16
|
+
*
|
|
17
|
+
* Cleanup: `dispose()` runs from `disconnectedCallback`. All listener
|
|
18
|
+
* sets are cleared; subsequent notifyProps/notifyContent are no-ops; the
|
|
19
|
+
* `events.emit` becomes a no-op too. A fresh adapter is allocated on the
|
|
20
|
+
* next `this.reactive` access (i.e. after reconnect).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type PropsListener<Props> = (props: Props, errors: readonly string[]) => void
|
|
24
|
+
export type ContentListener = (content: string | null) => void
|
|
25
|
+
|
|
26
|
+
export interface SubscribeOptions {
|
|
27
|
+
/** Cancel the subscription when the signal aborts. */
|
|
28
|
+
signal?: AbortSignal
|
|
29
|
+
/** Fire the listener once synchronously with the current value. */
|
|
30
|
+
immediate?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PropsSubscription<Props> {
|
|
34
|
+
/** Current parsed props. Always reflects the latest state. */
|
|
35
|
+
readonly value: Props
|
|
36
|
+
/** Current validation/parse errors. */
|
|
37
|
+
readonly errors: readonly string[]
|
|
38
|
+
/**
|
|
39
|
+
* Register a listener that fires whenever props change. Returns an
|
|
40
|
+
* unsubscribe function. Pass `options.signal` for AbortSignal-driven
|
|
41
|
+
* cleanup (e.g. tied to an element's lifetime). Pass
|
|
42
|
+
* `options.immediate: true` to fire once synchronously with the current
|
|
43
|
+
* value — useful when the subscriber is set up mid-flight.
|
|
44
|
+
*
|
|
45
|
+
* Note: `notifyProps` runs BEFORE the subclass `onPropsChanged()` hook.
|
|
46
|
+
* A subscriber added from inside `onPropsChanged()` therefore does NOT
|
|
47
|
+
* receive the currently-firing change — only the next one. Use
|
|
48
|
+
* `{ immediate: true }` to also see the current value.
|
|
49
|
+
*/
|
|
50
|
+
subscribe: (listener: PropsListener<Props>, options?: SubscribeOptions) => () => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContentSubscription {
|
|
54
|
+
/** Current `<template>` content text, or `null` if not set. */
|
|
55
|
+
readonly value: string | null
|
|
56
|
+
/** See `PropsSubscription.subscribe` for the same ordering caveat. */
|
|
57
|
+
subscribe: (listener: ContentListener, options?: SubscribeOptions) => () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EventEmitterHandle<Events> {
|
|
61
|
+
/**
|
|
62
|
+
* Dispatch a typed `CustomEvent` (bubbles + composed, identical
|
|
63
|
+
* semantics to the protected `emitEvent` helper). The key is constrained
|
|
64
|
+
* to `keyof Events & string`, so consumers narrow `Events` to get
|
|
65
|
+
* autocomplete + payload-type checks.
|
|
66
|
+
*
|
|
67
|
+
* No-op after the adapter is disposed (i.e. after the element is
|
|
68
|
+
* detached). The element is the only event target — subscribers on
|
|
69
|
+
* the element itself receive the event.
|
|
70
|
+
*/
|
|
71
|
+
emit: <K extends keyof Events & string>(event: K, detail: Events[K]) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ReactiveAdapter<Props, Events> {
|
|
75
|
+
props: PropsSubscription<Props>
|
|
76
|
+
events: EventEmitterHandle<Events>
|
|
77
|
+
/**
|
|
78
|
+
* Present only when the element's `wippyConfig.contentTemplate` is set;
|
|
79
|
+
* `null` otherwise. Avoids fake `.value === null` semantics for elements
|
|
80
|
+
* that don't track children content.
|
|
81
|
+
*/
|
|
82
|
+
content: ContentSubscription | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Concrete implementation used by `WippyElement`. The `notify*` methods
|
|
87
|
+
* are intentionally exposed (not private) so the base-element can call
|
|
88
|
+
* them; consumers should never reach for them.
|
|
89
|
+
*
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
export class ReactiveAdapterImpl<Props, Events> implements ReactiveAdapter<Props, Events> {
|
|
93
|
+
private _props: Props
|
|
94
|
+
private _errors: readonly string[]
|
|
95
|
+
private _content: string | null
|
|
96
|
+
private _propsListeners = new Set<PropsListener<Props>>()
|
|
97
|
+
private _contentListeners = new Set<ContentListener>()
|
|
98
|
+
private _disposed = false
|
|
99
|
+
private _emitToDom: (eventName: string, detail?: unknown) => void
|
|
100
|
+
|
|
101
|
+
readonly props: PropsSubscription<Props>
|
|
102
|
+
readonly events: EventEmitterHandle<Events>
|
|
103
|
+
readonly content: ContentSubscription | null
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
initial: {
|
|
107
|
+
props: Props
|
|
108
|
+
errors: readonly string[]
|
|
109
|
+
content: string | null
|
|
110
|
+
hasContent: boolean
|
|
111
|
+
},
|
|
112
|
+
emitToDom: (eventName: string, detail?: unknown) => void,
|
|
113
|
+
) {
|
|
114
|
+
this._props = initial.props
|
|
115
|
+
this._errors = initial.errors
|
|
116
|
+
this._content = initial.content
|
|
117
|
+
this._emitToDom = emitToDom
|
|
118
|
+
|
|
119
|
+
// Each getter / closure below runs with the surrounding object literal
|
|
120
|
+
// as `this`, not the class instance — so we have to capture the
|
|
121
|
+
// instance explicitly here.
|
|
122
|
+
// eslint-disable-next-line ts/no-this-alias
|
|
123
|
+
const self = this
|
|
124
|
+
|
|
125
|
+
this.props = {
|
|
126
|
+
get value() {
|
|
127
|
+
return self._props
|
|
128
|
+
},
|
|
129
|
+
get errors() {
|
|
130
|
+
return self._errors
|
|
131
|
+
},
|
|
132
|
+
subscribe(listener, options) {
|
|
133
|
+
return self._subscribeProps(listener, options)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.events = {
|
|
138
|
+
emit(event, detail) {
|
|
139
|
+
if (self._disposed)
|
|
140
|
+
return
|
|
141
|
+
self._emitToDom(event as string, detail)
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.content = initial.hasContent
|
|
146
|
+
? {
|
|
147
|
+
get value() {
|
|
148
|
+
return self._content
|
|
149
|
+
},
|
|
150
|
+
subscribe(listener, options) {
|
|
151
|
+
return self._subscribeContent(listener, options)
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @internal */
|
|
158
|
+
notifyProps(props: Props, errors: readonly string[]): void {
|
|
159
|
+
if (this._disposed)
|
|
160
|
+
return
|
|
161
|
+
this._props = props
|
|
162
|
+
this._errors = errors
|
|
163
|
+
for (const listener of this._propsListeners)
|
|
164
|
+
listener(props, errors)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @internal */
|
|
168
|
+
notifyContent(content: string | null): void {
|
|
169
|
+
if (this._disposed)
|
|
170
|
+
return
|
|
171
|
+
this._content = content
|
|
172
|
+
for (const listener of this._contentListeners)
|
|
173
|
+
listener(content)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @internal */
|
|
177
|
+
dispose(): void {
|
|
178
|
+
if (this._disposed)
|
|
179
|
+
return
|
|
180
|
+
this._disposed = true
|
|
181
|
+
this._propsListeners.clear()
|
|
182
|
+
this._contentListeners.clear()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private _subscribeProps(listener: PropsListener<Props>, options?: SubscribeOptions): () => void {
|
|
186
|
+
if (this._disposed || options?.signal?.aborted)
|
|
187
|
+
return () => {}
|
|
188
|
+
// Add to the set BEFORE firing `immediate` so a re-entrant
|
|
189
|
+
// `notifyProps` triggered from inside the immediate callback can
|
|
190
|
+
// find this listener and fire it for the synchronous change too.
|
|
191
|
+
this._propsListeners.add(listener)
|
|
192
|
+
if (options?.immediate)
|
|
193
|
+
listener(this._props, this._errors)
|
|
194
|
+
const unsub = () => {
|
|
195
|
+
this._propsListeners.delete(listener)
|
|
196
|
+
// Manual unsub must also detach the abort listener — otherwise it
|
|
197
|
+
// accumulates on long-lived signals (e.g. an AbortController kept
|
|
198
|
+
// around for the element's whole lifetime that fans out to many
|
|
199
|
+
// subscriptions). `{ once: true }` only auto-removes when 'abort'
|
|
200
|
+
// actually fires.
|
|
201
|
+
options?.signal?.removeEventListener('abort', unsub)
|
|
202
|
+
}
|
|
203
|
+
options?.signal?.addEventListener('abort', unsub, { once: true })
|
|
204
|
+
return unsub
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private _subscribeContent(listener: ContentListener, options?: SubscribeOptions): () => void {
|
|
208
|
+
if (this._disposed || options?.signal?.aborted)
|
|
209
|
+
return () => {}
|
|
210
|
+
this._contentListeners.add(listener)
|
|
211
|
+
if (options?.immediate)
|
|
212
|
+
listener(this._content)
|
|
213
|
+
const unsub = () => {
|
|
214
|
+
this._contentListeners.delete(listener)
|
|
215
|
+
options?.signal?.removeEventListener('abort', unsub)
|
|
216
|
+
}
|
|
217
|
+
options?.signal?.addEventListener('abort', unsub, { once: true })
|
|
218
|
+
return unsub
|
|
219
|
+
}
|
|
220
|
+
}
|