@pyreon/runtime-dom 0.11.3 → 0.11.5
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/src/keep-alive.ts +2 -1
- package/src/tests/props.test.ts +463 -0
- package/src/tests/transition.test.ts +550 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import type { ComponentFn } from "@pyreon/core"
|
|
2
|
+
import { h } from "@pyreon/core"
|
|
3
|
+
import { signal } from "@pyreon/reactivity"
|
|
4
|
+
import {
|
|
5
|
+
KeepAlive as _KeepAlive,
|
|
6
|
+
Transition as _Transition,
|
|
7
|
+
TransitionGroup as _TransitionGroup,
|
|
8
|
+
mount,
|
|
9
|
+
} from "../index"
|
|
10
|
+
|
|
11
|
+
const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
|
|
12
|
+
const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
|
|
13
|
+
const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
|
|
14
|
+
|
|
15
|
+
function container(): HTMLElement {
|
|
16
|
+
const el = document.createElement("div")
|
|
17
|
+
document.body.appendChild(el)
|
|
18
|
+
return el
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Transition ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe("Transition", () => {
|
|
24
|
+
test("renders child when show is true", () => {
|
|
25
|
+
const el = container()
|
|
26
|
+
const show = signal(true)
|
|
27
|
+
mount(
|
|
28
|
+
h(Transition, { name: "fade", show: () => show() }, h("div", { class: "child" }, "hello")),
|
|
29
|
+
el,
|
|
30
|
+
)
|
|
31
|
+
expect(el.querySelector(".child")?.textContent).toBe("hello")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("does not render child when show is false initially", () => {
|
|
35
|
+
const el = container()
|
|
36
|
+
const show = signal(false)
|
|
37
|
+
mount(
|
|
38
|
+
h(Transition, { name: "fade", show: () => show() }, h("div", { class: "child" }, "hello")),
|
|
39
|
+
el,
|
|
40
|
+
)
|
|
41
|
+
expect(el.querySelector(".child")).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("uses default name 'pyreon' when no name provided", () => {
|
|
45
|
+
const el = container()
|
|
46
|
+
const show = signal(true)
|
|
47
|
+
mount(h(Transition, { show: () => show() }, h("div", { class: "child" }, "content")), el)
|
|
48
|
+
expect(el.querySelector(".child")?.textContent).toBe("content")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("applies enter classes when show transitions from false to true", async () => {
|
|
52
|
+
const el = container()
|
|
53
|
+
const show = signal(false)
|
|
54
|
+
mount(
|
|
55
|
+
h(Transition, { name: "fade", show: () => show() }, h("div", { class: "target" }, "text")),
|
|
56
|
+
el,
|
|
57
|
+
)
|
|
58
|
+
expect(el.querySelector(".target")).toBeNull()
|
|
59
|
+
|
|
60
|
+
show.set(true)
|
|
61
|
+
// Wait for microtask (queueMicrotask in handleVisibilityChange)
|
|
62
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
63
|
+
|
|
64
|
+
const target = el.querySelector(".target") as HTMLElement
|
|
65
|
+
expect(target).not.toBeNull()
|
|
66
|
+
// After the enter animation starts, the element should have enter classes
|
|
67
|
+
// Classes will be in transition — at minimum the element should exist
|
|
68
|
+
expect(target.textContent).toBe("text")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("applies custom enter/leave class overrides", async () => {
|
|
72
|
+
const el = container()
|
|
73
|
+
const show = signal(true)
|
|
74
|
+
mount(
|
|
75
|
+
h(
|
|
76
|
+
Transition,
|
|
77
|
+
{
|
|
78
|
+
show: () => show(),
|
|
79
|
+
enterFrom: "my-enter-from",
|
|
80
|
+
enterActive: "my-enter-active",
|
|
81
|
+
enterTo: "my-enter-to",
|
|
82
|
+
leaveFrom: "my-leave-from",
|
|
83
|
+
leaveActive: "my-leave-active",
|
|
84
|
+
leaveTo: "my-leave-to",
|
|
85
|
+
},
|
|
86
|
+
h("div", { class: "custom-target" }, "custom"),
|
|
87
|
+
),
|
|
88
|
+
el,
|
|
89
|
+
)
|
|
90
|
+
expect(el.querySelector(".custom-target")).not.toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("calls lifecycle callbacks on enter", async () => {
|
|
94
|
+
const el = container()
|
|
95
|
+
const show = signal(false)
|
|
96
|
+
const onBeforeEnter = vi.fn()
|
|
97
|
+
const onAfterEnter = vi.fn()
|
|
98
|
+
|
|
99
|
+
mount(
|
|
100
|
+
h(
|
|
101
|
+
Transition,
|
|
102
|
+
{
|
|
103
|
+
name: "fade",
|
|
104
|
+
show: () => show(),
|
|
105
|
+
onBeforeEnter,
|
|
106
|
+
onAfterEnter,
|
|
107
|
+
},
|
|
108
|
+
h("div", { class: "lifecycle" }, "enter"),
|
|
109
|
+
),
|
|
110
|
+
el,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
show.set(true)
|
|
114
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
115
|
+
expect(onBeforeEnter).toHaveBeenCalled()
|
|
116
|
+
|
|
117
|
+
// Trigger the transitionend to complete the enter
|
|
118
|
+
const target = el.querySelector(".lifecycle") as HTMLElement
|
|
119
|
+
if (target) {
|
|
120
|
+
target.dispatchEvent(new Event("transitionend"))
|
|
121
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
122
|
+
expect(onAfterEnter).toHaveBeenCalled()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("calls lifecycle callbacks on leave", async () => {
|
|
127
|
+
const el = container()
|
|
128
|
+
const show = signal(true)
|
|
129
|
+
const onBeforeLeave = vi.fn()
|
|
130
|
+
const onAfterLeave = vi.fn()
|
|
131
|
+
|
|
132
|
+
mount(
|
|
133
|
+
h(
|
|
134
|
+
Transition,
|
|
135
|
+
{
|
|
136
|
+
name: "fade",
|
|
137
|
+
show: () => show(),
|
|
138
|
+
onBeforeLeave,
|
|
139
|
+
onAfterLeave,
|
|
140
|
+
},
|
|
141
|
+
h("div", { class: "leave-target" }, "leave"),
|
|
142
|
+
),
|
|
143
|
+
el,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Initial render
|
|
147
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
148
|
+
|
|
149
|
+
show.set(false)
|
|
150
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
151
|
+
expect(onBeforeLeave).toHaveBeenCalled()
|
|
152
|
+
|
|
153
|
+
// Trigger transitionend to complete leave
|
|
154
|
+
const target = el.querySelector(".leave-target") as HTMLElement
|
|
155
|
+
if (target) {
|
|
156
|
+
target.dispatchEvent(new Event("transitionend"))
|
|
157
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
158
|
+
expect(onAfterLeave).toHaveBeenCalled()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("appear option triggers enter animation on initial mount", async () => {
|
|
163
|
+
const el = container()
|
|
164
|
+
const show = signal(true)
|
|
165
|
+
const onBeforeEnter = vi.fn()
|
|
166
|
+
|
|
167
|
+
mount(
|
|
168
|
+
h(
|
|
169
|
+
Transition,
|
|
170
|
+
{
|
|
171
|
+
name: "fade",
|
|
172
|
+
show: () => show(),
|
|
173
|
+
appear: true,
|
|
174
|
+
onBeforeEnter,
|
|
175
|
+
},
|
|
176
|
+
h("div", { class: "appear-target" }, "appear"),
|
|
177
|
+
),
|
|
178
|
+
el,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
182
|
+
expect(onBeforeEnter).toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test("warns when child is a component (not a DOM element)", () => {
|
|
186
|
+
const el = container()
|
|
187
|
+
const show = signal(true)
|
|
188
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
189
|
+
const ChildComp = () => h("div", null, "comp-child")
|
|
190
|
+
|
|
191
|
+
mount(h(Transition, { name: "fade", show: () => show() }, h(ChildComp, null)), el)
|
|
192
|
+
|
|
193
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Transition child is a component"))
|
|
194
|
+
warnSpy.mockRestore()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("handles null/undefined children gracefully", () => {
|
|
198
|
+
const el = container()
|
|
199
|
+
const show = signal(true)
|
|
200
|
+
// No children
|
|
201
|
+
expect(() => mount(h(Transition, { name: "fade", show: () => show() }), el)).not.toThrow()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("cancels pending leave when re-entering", async () => {
|
|
205
|
+
const el = container()
|
|
206
|
+
const show = signal(true)
|
|
207
|
+
|
|
208
|
+
mount(
|
|
209
|
+
h(
|
|
210
|
+
Transition,
|
|
211
|
+
{ name: "fade", show: () => show() },
|
|
212
|
+
h("div", { class: "cancel-test" }, "toggle"),
|
|
213
|
+
),
|
|
214
|
+
el,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
218
|
+
|
|
219
|
+
// Start leave
|
|
220
|
+
show.set(false)
|
|
221
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
222
|
+
|
|
223
|
+
// Re-enter before leave animation completes
|
|
224
|
+
show.set(true)
|
|
225
|
+
await new Promise<void>((r) => setTimeout(r, 30))
|
|
226
|
+
|
|
227
|
+
// Element should be visible again
|
|
228
|
+
const target = el.querySelector(".cancel-test")
|
|
229
|
+
expect(target).not.toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("handles animationend event (CSS animations)", async () => {
|
|
233
|
+
const el = container()
|
|
234
|
+
const show = signal(false)
|
|
235
|
+
const onAfterEnter = vi.fn()
|
|
236
|
+
|
|
237
|
+
mount(
|
|
238
|
+
h(
|
|
239
|
+
Transition,
|
|
240
|
+
{ name: "anim", show: () => show(), onAfterEnter },
|
|
241
|
+
h("div", { class: "anim-target" }, "anim"),
|
|
242
|
+
),
|
|
243
|
+
el,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
show.set(true)
|
|
247
|
+
await new Promise<void>((r) => setTimeout(r, 30))
|
|
248
|
+
|
|
249
|
+
const target = el.querySelector(".anim-target") as HTMLElement
|
|
250
|
+
if (target) {
|
|
251
|
+
// Fire animationend instead of transitionend
|
|
252
|
+
target.dispatchEvent(new Event("animationend"))
|
|
253
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
254
|
+
expect(onAfterEnter).toHaveBeenCalled()
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ─── TransitionGroup ─────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("TransitionGroup", () => {
|
|
262
|
+
test("renders items inside a wrapper element", async () => {
|
|
263
|
+
const el = container()
|
|
264
|
+
const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
265
|
+
|
|
266
|
+
mount(
|
|
267
|
+
h(TransitionGroup, {
|
|
268
|
+
tag: "ul",
|
|
269
|
+
name: "list",
|
|
270
|
+
items: () => items(),
|
|
271
|
+
keyFn: (item: { id: number }) => item.id,
|
|
272
|
+
render: (item: { id: number }) => h("li", null, `item-${item.id}`),
|
|
273
|
+
}),
|
|
274
|
+
el,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
278
|
+
const lis = el.querySelectorAll("li")
|
|
279
|
+
expect(lis.length).toBe(3)
|
|
280
|
+
expect(lis[0]?.textContent).toBe("item-1")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("uses default tag 'div' and name 'pyreon'", async () => {
|
|
284
|
+
const el = container()
|
|
285
|
+
const items = signal([{ id: 1 }])
|
|
286
|
+
|
|
287
|
+
mount(
|
|
288
|
+
h(TransitionGroup, {
|
|
289
|
+
items: () => items(),
|
|
290
|
+
keyFn: (item: { id: number }) => item.id,
|
|
291
|
+
render: (item: { id: number }) => h("span", null, `s-${item.id}`),
|
|
292
|
+
}),
|
|
293
|
+
el,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
297
|
+
expect(el.querySelector("div")).not.toBeNull()
|
|
298
|
+
expect(el.querySelector("span")?.textContent).toBe("s-1")
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test("handles item additions", async () => {
|
|
302
|
+
const el = container()
|
|
303
|
+
const items = signal([{ id: 1 }])
|
|
304
|
+
|
|
305
|
+
mount(
|
|
306
|
+
h(TransitionGroup, {
|
|
307
|
+
tag: "div",
|
|
308
|
+
name: "list",
|
|
309
|
+
items: () => items(),
|
|
310
|
+
keyFn: (item: { id: number }) => item.id,
|
|
311
|
+
render: (item: { id: number }) => h("span", null, `item-${item.id}`),
|
|
312
|
+
}),
|
|
313
|
+
el,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
317
|
+
expect(el.querySelectorAll("span").length).toBe(1)
|
|
318
|
+
|
|
319
|
+
items.set([{ id: 1 }, { id: 2 }])
|
|
320
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
321
|
+
expect(el.querySelectorAll("span").length).toBe(2)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test("handles item removals with leave animation", async () => {
|
|
325
|
+
const el = container()
|
|
326
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
327
|
+
|
|
328
|
+
mount(
|
|
329
|
+
h(TransitionGroup, {
|
|
330
|
+
tag: "div",
|
|
331
|
+
name: "list",
|
|
332
|
+
items: () => items(),
|
|
333
|
+
keyFn: (item: { id: number }) => item.id,
|
|
334
|
+
render: (item: { id: number }) => h("span", null, `item-${item.id}`),
|
|
335
|
+
}),
|
|
336
|
+
el,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
340
|
+
expect(el.querySelectorAll("span").length).toBe(2)
|
|
341
|
+
|
|
342
|
+
items.set([{ id: 1 }])
|
|
343
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
344
|
+
|
|
345
|
+
// The removed item gets leave animation classes.
|
|
346
|
+
// After transitionend it would be removed. Simulate that.
|
|
347
|
+
const spans = el.querySelectorAll("span")
|
|
348
|
+
for (const span of spans) {
|
|
349
|
+
span.dispatchEvent(new Event("transitionend"))
|
|
350
|
+
}
|
|
351
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test("calls lifecycle callbacks on enter/leave", async () => {
|
|
355
|
+
const el = container()
|
|
356
|
+
const items = signal([{ id: 1 }])
|
|
357
|
+
const onBeforeEnter = vi.fn()
|
|
358
|
+
const onAfterEnter = vi.fn()
|
|
359
|
+
const onBeforeLeave = vi.fn()
|
|
360
|
+
|
|
361
|
+
mount(
|
|
362
|
+
h(TransitionGroup, {
|
|
363
|
+
tag: "div",
|
|
364
|
+
name: "list",
|
|
365
|
+
items: () => items(),
|
|
366
|
+
keyFn: (item: { id: number }) => item.id,
|
|
367
|
+
render: (item: { id: number }) => h("span", null, `item-${item.id}`),
|
|
368
|
+
onBeforeEnter,
|
|
369
|
+
onAfterEnter,
|
|
370
|
+
onBeforeLeave,
|
|
371
|
+
}),
|
|
372
|
+
el,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
// Wait for initial mount
|
|
376
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
377
|
+
|
|
378
|
+
// Add an item to trigger enter animation
|
|
379
|
+
items.set([{ id: 1 }, { id: 2 }])
|
|
380
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
381
|
+
expect(onBeforeEnter).toHaveBeenCalled()
|
|
382
|
+
|
|
383
|
+
// Trigger transitionend on the new item to fire onAfterEnter
|
|
384
|
+
const spans = el.querySelectorAll("span")
|
|
385
|
+
const newSpan = spans[spans.length - 1]
|
|
386
|
+
if (newSpan) {
|
|
387
|
+
newSpan.dispatchEvent(new Event("transitionend"))
|
|
388
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
389
|
+
expect(onAfterEnter).toHaveBeenCalled()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Remove item to trigger leave
|
|
393
|
+
items.set([{ id: 1 }])
|
|
394
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
395
|
+
expect(onBeforeLeave).toHaveBeenCalled()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test("appear option animates items on initial mount", async () => {
|
|
399
|
+
const el = container()
|
|
400
|
+
const items = signal([{ id: 1 }])
|
|
401
|
+
const onBeforeEnter = vi.fn()
|
|
402
|
+
|
|
403
|
+
mount(
|
|
404
|
+
h(TransitionGroup, {
|
|
405
|
+
tag: "div",
|
|
406
|
+
name: "list",
|
|
407
|
+
appear: true,
|
|
408
|
+
items: () => items(),
|
|
409
|
+
keyFn: (item: { id: number }) => item.id,
|
|
410
|
+
render: (item: { id: number }) => h("span", null, `item-${item.id}`),
|
|
411
|
+
onBeforeEnter,
|
|
412
|
+
}),
|
|
413
|
+
el,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
417
|
+
expect(onBeforeEnter).toHaveBeenCalled()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test("supports custom class overrides", async () => {
|
|
421
|
+
const el = container()
|
|
422
|
+
const items = signal([{ id: 1 }])
|
|
423
|
+
|
|
424
|
+
mount(
|
|
425
|
+
h(TransitionGroup, {
|
|
426
|
+
tag: "div",
|
|
427
|
+
items: () => items(),
|
|
428
|
+
keyFn: (item: { id: number }) => item.id,
|
|
429
|
+
render: (item: { id: number }) => h("span", null, `item-${item.id}`),
|
|
430
|
+
enterFrom: "custom-enter-from",
|
|
431
|
+
enterActive: "custom-enter-active",
|
|
432
|
+
enterTo: "custom-enter-to",
|
|
433
|
+
leaveFrom: "custom-leave-from",
|
|
434
|
+
leaveActive: "custom-leave-active",
|
|
435
|
+
leaveTo: "custom-leave-to",
|
|
436
|
+
moveClass: "custom-move",
|
|
437
|
+
}),
|
|
438
|
+
el,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
442
|
+
expect(el.querySelector("span")).not.toBeNull()
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// ─── KeepAlive ───────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
describe("KeepAlive", () => {
|
|
449
|
+
test("renders children when active is true", async () => {
|
|
450
|
+
const el = container()
|
|
451
|
+
const active = signal(true)
|
|
452
|
+
|
|
453
|
+
mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
|
|
454
|
+
|
|
455
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
456
|
+
const kept = el.querySelector(".kept")
|
|
457
|
+
expect(kept?.textContent).toBe("alive")
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test("hides children but keeps them mounted when active is false", async () => {
|
|
461
|
+
const el = container()
|
|
462
|
+
const active = signal(true)
|
|
463
|
+
|
|
464
|
+
mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
|
|
465
|
+
|
|
466
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
467
|
+
|
|
468
|
+
active.set(false)
|
|
469
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
470
|
+
|
|
471
|
+
// The container div should have display: none, but the child should still be in DOM
|
|
472
|
+
const wrapperDiv = el.querySelector("[style]") as HTMLElement
|
|
473
|
+
if (wrapperDiv) {
|
|
474
|
+
expect(wrapperDiv.style.display).toBe("none")
|
|
475
|
+
}
|
|
476
|
+
// Child should still exist in the DOM (kept alive)
|
|
477
|
+
expect(el.querySelector(".kept")).not.toBeNull()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test("restores display when re-activated", async () => {
|
|
481
|
+
const el = container()
|
|
482
|
+
const active = signal(true)
|
|
483
|
+
|
|
484
|
+
mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
|
|
485
|
+
|
|
486
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
487
|
+
active.set(false)
|
|
488
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
489
|
+
active.set(true)
|
|
490
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
491
|
+
|
|
492
|
+
// The container's display should be restored (empty string = visible)
|
|
493
|
+
const wrapperDivs = el.querySelectorAll("div")
|
|
494
|
+
let foundVisible = false
|
|
495
|
+
for (const div of wrapperDivs) {
|
|
496
|
+
if (div.style.display === "" || div.style.display === "contents") {
|
|
497
|
+
foundVisible = true
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
expect(foundVisible).toBe(true)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test("defaults to active=true when no active prop provided", async () => {
|
|
504
|
+
const el = container()
|
|
505
|
+
|
|
506
|
+
mount(h(KeepAlive, {}, h("span", { class: "default" }, "default")), el)
|
|
507
|
+
|
|
508
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
509
|
+
expect(el.querySelector(".default")?.textContent).toBe("default")
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test("mounts children only once (not re-created on toggle)", async () => {
|
|
513
|
+
const el = container()
|
|
514
|
+
const active = signal(true)
|
|
515
|
+
let mountCount = 0
|
|
516
|
+
const Counter = () => {
|
|
517
|
+
mountCount++
|
|
518
|
+
return h("span", null, "counter")
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
mount(h(KeepAlive, { active: () => active() }, h(Counter, null)), el)
|
|
522
|
+
|
|
523
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
524
|
+
expect(mountCount).toBe(1)
|
|
525
|
+
|
|
526
|
+
active.set(false)
|
|
527
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
528
|
+
active.set(true)
|
|
529
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
530
|
+
|
|
531
|
+
// Component should NOT be re-created
|
|
532
|
+
expect(mountCount).toBe(1)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test("uses display: contents wrapper for transparent layout", async () => {
|
|
536
|
+
const el = container()
|
|
537
|
+
mount(h(KeepAlive, {}, h("span", null, "child")), el)
|
|
538
|
+
|
|
539
|
+
await new Promise<void>((r) => setTimeout(r, 20))
|
|
540
|
+
// KeepAlive renders a div with style="display: contents"
|
|
541
|
+
const wrapper = el.querySelector("div")
|
|
542
|
+
expect(wrapper).not.toBeNull()
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test("handles null children gracefully", async () => {
|
|
546
|
+
const el = container()
|
|
547
|
+
expect(() => mount(h(KeepAlive, {}), el)).not.toThrow()
|
|
548
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
549
|
+
})
|
|
550
|
+
})
|