@wippy-fe/webcomponent-core 0.0.34 → 0.0.35

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.
@@ -1,310 +0,0 @@
1
- /**
2
- * @vitest-environment jsdom
3
- *
4
- * Covers `WippyElement.host` getter — reads `__wippyHost` attached by
5
- * the managed-layout shell's content resolvers. And the cleanup path
6
- * in `disconnectedCallback` that drops stale wires on detach.
7
- */
8
- import { describe, expect, it, vi } from 'vitest'
9
- import { WippyElement } from './base-element.ts'
10
-
11
- // `ElementInternals.states` shim lives in `vitest.setup.ts` (gen-2-chat
12
- // repo root) so any test that transitively imports `WippyElement` gets
13
- // the patch. This file just exercises the resulting behavior.
14
-
15
- class TestEl extends WippyElement {
16
- static get wippyConfig() {
17
- return { propsSchema: { properties: {} }, hostCssKeys: [] }
18
- }
19
-
20
- protected onMount(): void {}
21
- protected onUnmount(): void {}
22
- }
23
-
24
- const TAG = `wippy-host-test-${Math.random().toString(36).slice(2)}`
25
- customElements.define(TAG, TestEl)
26
-
27
- describe('wippyElement.host getter', () => {
28
- it('returns null on a fresh element with no wires', () => {
29
- const el = document.createElement(TAG) as TestEl
30
- expect(el.host).toBeNull()
31
- })
32
-
33
- it('returns the wrapper attached by the resolver as __wippyHost', () => {
34
- const el = document.createElement(TAG) as TestEl
35
- const fakeWrapper = { layout: { broadcast: () => {} } }
36
- ;(el as unknown as { __wippyHost: unknown }).__wippyHost = fakeWrapper
37
- expect(el.host).toBe(fakeWrapper)
38
- })
39
-
40
- it('connectedCallback can read this.host on first tick', () => {
41
- let observedHost: unknown = 'sentinel'
42
- class Probe extends WippyElement {
43
- static get wippyConfig() {
44
- return { propsSchema: { properties: {} }, hostCssKeys: [] }
45
- }
46
-
47
- protected onMount(): void {
48
- observedHost = this.host
49
- }
50
-
51
- protected onUnmount(): void {}
52
- }
53
- const probeTag = `wippy-probe-${Math.random().toString(36).slice(2)}`
54
- customElements.define(probeTag, Probe)
55
- const el = document.createElement(probeTag)
56
- const fakeWrapper = { layout: {} }
57
- ;(el as unknown as { __wippyHost: unknown }).__wippyHost = fakeWrapper
58
- document.body.appendChild(el)
59
- expect(observedHost).toBe(fakeWrapper)
60
- el.remove()
61
- })
62
- })
63
-
64
- describe('wippyElement.disconnectedCallback wire cleanup', () => {
65
- it('drops __wippyHost and __wippyHostBus on disconnect', () => {
66
- const el = document.createElement(TAG) as TestEl
67
- ;(el as unknown as { __wippyHost: unknown }).__wippyHost = { layout: {} }
68
- ;(el as unknown as { __wippyHostBus: unknown }).__wippyHostBus = {
69
- broadcast: () => {},
70
- send: () => {},
71
- subscribe: () => () => {},
72
- }
73
- document.body.appendChild(el)
74
- el.remove()
75
- expect((el as unknown as { __wippyHost?: unknown }).__wippyHost).toBeUndefined()
76
- expect((el as unknown as { __wippyHostBus?: unknown }).__wippyHostBus).toBeUndefined()
77
- })
78
- })
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
-
216
- describe('wippyElement reconnect — no shadow-child accumulation', () => {
217
- // R3 regression: previously `connectedCallback` re-ran the full mount
218
- // path (CSS injection + container append) on every reconnect. The
219
- // `disconnectedCallback` did NOT clear the persistent shadow root, so
220
- // each detach+reattach cycle leaked another `<style>` and another
221
- // container `<div>`. On a move-style DOM op (e.g. `appendChild` of an
222
- // already-connected element, list reorder) the browser fires
223
- // disconnect → connect synchronously, so this leak fires on every
224
- // reorder.
225
- //
226
- // Fix: gate CSS / container / icon setup on a `_initialized` flag and
227
- // reuse the stored container on reconnect.
228
-
229
- class CssEl extends WippyElement {
230
- static get wippyConfig() {
231
- return {
232
- propsSchema: { properties: {} },
233
- hostCssKeys: [],
234
- inlineCss: ':host { display: block; }',
235
- containerClasses: ['ct'],
236
- }
237
- }
238
-
239
- public mountCount = 0
240
- public lastIsRemount: boolean | undefined = undefined
241
- public lastContainer: HTMLElement | null = null
242
-
243
- protected onMount(
244
- _shadow: ShadowRoot,
245
- container: HTMLElement,
246
- _props: Record<string, unknown>,
247
- _errors: string[],
248
- _content?: string | null,
249
- isRemount?: boolean,
250
- ): void {
251
- this.mountCount += 1
252
- this.lastIsRemount = isRemount
253
- this.lastContainer = container
254
- }
255
-
256
- protected onUnmount(): void {}
257
- }
258
- const cssTag = `wippy-reconnect-${Math.random().toString(36).slice(2)}`
259
- customElements.define(cssTag, CssEl)
260
-
261
- it('does not duplicate <style> or container <div> across detach/reattach cycles', () => {
262
- const el = document.createElement(cssTag) as CssEl
263
- document.body.appendChild(el)
264
-
265
- const shadow = el.shadowRoot!
266
- const initialChildCount = shadow.childElementCount
267
- const initialStyles = shadow.querySelectorAll('style').length
268
- const initialContainers = shadow.querySelectorAll('div.ct').length
269
- expect(initialContainers).toBe(1)
270
- expect(initialStyles).toBeGreaterThan(0)
271
- expect(el.mountCount).toBe(1)
272
- expect(el.lastIsRemount).toBe(false)
273
-
274
- // Detach + reattach (one cycle) — pre-fix this would add another
275
- // <style> and another container <div>.
276
- el.remove()
277
- document.body.appendChild(el)
278
-
279
- expect(shadow.childElementCount).toBe(initialChildCount)
280
- expect(shadow.querySelectorAll('style').length).toBe(initialStyles)
281
- expect(shadow.querySelectorAll('div.ct').length).toBe(initialContainers)
282
- expect(el.mountCount).toBe(2)
283
- expect(el.lastIsRemount).toBe(true)
284
-
285
- // Second detach + reattach cycle — counts still stable.
286
- el.remove()
287
- document.body.appendChild(el)
288
-
289
- expect(shadow.childElementCount).toBe(initialChildCount)
290
- expect(shadow.querySelectorAll('style').length).toBe(initialStyles)
291
- expect(shadow.querySelectorAll('div.ct').length).toBe(initialContainers)
292
- expect(el.mountCount).toBe(3)
293
- expect(el.lastIsRemount).toBe(true)
294
-
295
- el.remove()
296
- })
297
-
298
- it('reuses the same container instance on reconnect', () => {
299
- const el = document.createElement(cssTag) as CssEl
300
- document.body.appendChild(el)
301
- const firstContainer = el.lastContainer
302
- expect(firstContainer).not.toBeNull()
303
-
304
- el.remove()
305
- document.body.appendChild(el)
306
-
307
- expect(el.lastContainer).toBe(firstContainer)
308
- el.remove()
309
- })
310
- })