@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wippy-fe/webcomponent-core",
3
- "version": "0.0.30",
4
- "description": "Framework-agnostic base class for Wippy web components — shadow DOM, CSS loading, schema-driven prop parsing, and lifecycle management.",
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.30",
15
- "@wippy-fe/shared": "0.0.30"
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
@@ -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<Props = Record<string, unknown>> extends HTMLElement {
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
- this.onPropsChanged(props as Props, errors)
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
+ }