@pyreon/vue-compat 0.13.1 → 0.14.0
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/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +408 -28
- package/lib/index.js.map +1 -1
- package/lib/jsx-runtime.js +9 -0
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/types/index.d.ts +168 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/jsx-runtime.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +622 -21
- package/src/jsx-runtime.ts +15 -0
- package/src/tests/jsx-runtime-wrapper.test.ts +87 -0
- package/src/tests/new-apis.test.ts +1303 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
import type { ComponentFn } from '@pyreon/core'
|
|
2
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
3
|
+
import {
|
|
4
|
+
createApp,
|
|
5
|
+
customRef,
|
|
6
|
+
defineAsyncComponent,
|
|
7
|
+
defineComponent,
|
|
8
|
+
effectScope,
|
|
9
|
+
getCurrentScope,
|
|
10
|
+
h,
|
|
11
|
+
inject,
|
|
12
|
+
isProxy,
|
|
13
|
+
isReactive,
|
|
14
|
+
isReadonly,
|
|
15
|
+
isRef,
|
|
16
|
+
KeepAlive,
|
|
17
|
+
markRaw,
|
|
18
|
+
onErrorCaptured,
|
|
19
|
+
onRenderTracked,
|
|
20
|
+
onRenderTriggered,
|
|
21
|
+
onScopeDispose,
|
|
22
|
+
provide,
|
|
23
|
+
reactive,
|
|
24
|
+
readonly,
|
|
25
|
+
ref,
|
|
26
|
+
shallowReadonly,
|
|
27
|
+
Teleport,
|
|
28
|
+
toValue,
|
|
29
|
+
version,
|
|
30
|
+
watch,
|
|
31
|
+
watchEffect,
|
|
32
|
+
watchPostEffect,
|
|
33
|
+
watchSyncEffect,
|
|
34
|
+
} from '../index'
|
|
35
|
+
import {
|
|
36
|
+
beginRender,
|
|
37
|
+
endRender,
|
|
38
|
+
type RenderContext,
|
|
39
|
+
} from '../jsx-runtime'
|
|
40
|
+
|
|
41
|
+
// ─── Test helpers ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function container(): HTMLElement {
|
|
44
|
+
const el = document.createElement('div')
|
|
45
|
+
document.body.appendChild(el)
|
|
46
|
+
return el
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function withHookCtx<T>(fn: (ctx: RenderContext) => T): { result: T; ctx: RenderContext } {
|
|
50
|
+
const ctx: RenderContext = {
|
|
51
|
+
hooks: [],
|
|
52
|
+
scheduleRerender: () => {},
|
|
53
|
+
pendingEffects: [],
|
|
54
|
+
pendingLayoutEffects: [],
|
|
55
|
+
unmounted: false,
|
|
56
|
+
unmountCallbacks: [],
|
|
57
|
+
}
|
|
58
|
+
beginRender(ctx)
|
|
59
|
+
const result = fn(ctx)
|
|
60
|
+
endRender()
|
|
61
|
+
return { result, ctx }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('readonly() deep nesting', () => {
|
|
65
|
+
it('prevents mutation on nested objects', () => {
|
|
66
|
+
const ro = readonly({ nested: { x: 1 } })
|
|
67
|
+
expect(ro.nested.x).toBe(1)
|
|
68
|
+
expect(() => {
|
|
69
|
+
;(ro.nested as { x: number }).x = 2
|
|
70
|
+
}).toThrow('readonly')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('prevents mutation on deeply nested objects', () => {
|
|
74
|
+
const ro = readonly({ a: { b: { c: 3 } } })
|
|
75
|
+
expect(ro.a.b.c).toBe(3)
|
|
76
|
+
expect(() => {
|
|
77
|
+
;(ro.a.b as { c: number }).c = 99
|
|
78
|
+
}).toThrow('readonly')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('prevents delete on nested objects', () => {
|
|
82
|
+
const ro = readonly({ nested: { x: 1 } }) as Record<string, Record<string, unknown>>
|
|
83
|
+
expect(() => {
|
|
84
|
+
delete ro.nested!.x
|
|
85
|
+
}).toThrow('Cannot delete')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not wrap ref values in readonly recursively', () => {
|
|
89
|
+
const r = ref(42)
|
|
90
|
+
const ro = readonly({ myRef: r })
|
|
91
|
+
// Accessing the ref should return the ref itself, not a readonly proxy of it
|
|
92
|
+
expect(isRef(ro.myRef)).toBe(true)
|
|
93
|
+
expect(ro.myRef.value).toBe(42)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not wrap null or non-object values', () => {
|
|
97
|
+
const ro = readonly({ x: null, y: 5, z: 'hello' })
|
|
98
|
+
expect(ro.x).toBe(null)
|
|
99
|
+
expect(ro.y).toBe(5)
|
|
100
|
+
expect(ro.z).toBe('hello')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('nested readonly reports isReadonly', () => {
|
|
104
|
+
const ro = readonly({ nested: { x: 1 } })
|
|
105
|
+
expect(isReadonly(ro)).toBe(true)
|
|
106
|
+
expect(isReadonly(ro.nested)).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('readonly arrays are immutable', () => {
|
|
110
|
+
const ro = readonly({ items: [1, 2, 3] })
|
|
111
|
+
expect(ro.items[0]).toBe(1)
|
|
112
|
+
expect(() => {
|
|
113
|
+
;(ro.items as number[])[0] = 99
|
|
114
|
+
}).toThrow('readonly')
|
|
115
|
+
expect(() => {
|
|
116
|
+
;(ro.items as number[]).push(4)
|
|
117
|
+
}).toThrow('readonly')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('isReactive()', () => {
|
|
122
|
+
it('returns true for reactive objects', () => {
|
|
123
|
+
const state = reactive({ count: 0 })
|
|
124
|
+
expect(isReactive(state)).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('returns false for plain objects', () => {
|
|
128
|
+
expect(isReactive({ a: 1 })).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('returns false for primitives', () => {
|
|
132
|
+
expect(isReactive(null)).toBe(false)
|
|
133
|
+
expect(isReactive(undefined)).toBe(false)
|
|
134
|
+
expect(isReactive(42)).toBe(false)
|
|
135
|
+
expect(isReactive('hello')).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns false for refs', () => {
|
|
139
|
+
const r = ref(0)
|
|
140
|
+
expect(isReactive(r)).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('returns false for readonly objects', () => {
|
|
144
|
+
const ro = readonly({ x: 1 })
|
|
145
|
+
expect(isReactive(ro)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('isReadonly()', () => {
|
|
150
|
+
it('returns true for readonly objects', () => {
|
|
151
|
+
const ro = readonly({ x: 1 })
|
|
152
|
+
expect(isReadonly(ro)).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns false for reactive objects', () => {
|
|
156
|
+
const state = reactive({ x: 1 })
|
|
157
|
+
expect(isReadonly(state)).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns false for plain objects', () => {
|
|
161
|
+
expect(isReadonly({ x: 1 })).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('returns false for primitives', () => {
|
|
165
|
+
expect(isReadonly(null)).toBe(false)
|
|
166
|
+
expect(isReadonly(undefined)).toBe(false)
|
|
167
|
+
expect(isReadonly(42)).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('isProxy()', () => {
|
|
172
|
+
it('returns true for reactive objects', () => {
|
|
173
|
+
expect(isProxy(reactive({ x: 1 }))).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns true for readonly objects', () => {
|
|
177
|
+
expect(isProxy(readonly({ x: 1 }))).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('returns false for plain objects', () => {
|
|
181
|
+
expect(isProxy({ x: 1 })).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('returns false for refs', () => {
|
|
185
|
+
expect(isProxy(ref(0))).toBe(false)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('returns false for primitives', () => {
|
|
189
|
+
expect(isProxy(null)).toBe(false)
|
|
190
|
+
expect(isProxy(42)).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('markRaw()', () => {
|
|
195
|
+
it('prevents reactive wrapping', () => {
|
|
196
|
+
const raw = markRaw({ count: 0 })
|
|
197
|
+
const result = reactive(raw)
|
|
198
|
+
// Should return the same object — not wrapped
|
|
199
|
+
expect(result).toBe(raw)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns the same object', () => {
|
|
203
|
+
const obj = { a: 1 }
|
|
204
|
+
expect(markRaw(obj)).toBe(obj)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('marked object is not reactive', () => {
|
|
208
|
+
const raw = markRaw({ x: 1 })
|
|
209
|
+
const result = reactive(raw)
|
|
210
|
+
expect(isReactive(result)).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('effectScope()', () => {
|
|
215
|
+
it('collects and disposes effects', () => {
|
|
216
|
+
const scope = effectScope()
|
|
217
|
+
let runs = 0
|
|
218
|
+
const count = ref(0)
|
|
219
|
+
|
|
220
|
+
scope.run(() => {
|
|
221
|
+
watchEffect(() => {
|
|
222
|
+
void count.value
|
|
223
|
+
runs++
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(runs).toBe(1)
|
|
228
|
+
count.value = 1
|
|
229
|
+
expect(runs).toBe(2)
|
|
230
|
+
|
|
231
|
+
scope.stop()
|
|
232
|
+
count.value = 2
|
|
233
|
+
expect(runs).toBe(2) // Should not run after stop
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('run returns the function result', () => {
|
|
237
|
+
const scope = effectScope()
|
|
238
|
+
const result = scope.run(() => 42)
|
|
239
|
+
expect(result).toBe(42)
|
|
240
|
+
scope.stop()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('run returns undefined after stop', () => {
|
|
244
|
+
const scope = effectScope()
|
|
245
|
+
scope.stop()
|
|
246
|
+
const result = scope.run(() => 42)
|
|
247
|
+
expect(result).toBeUndefined()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('active is true until stopped', () => {
|
|
251
|
+
const scope = effectScope()
|
|
252
|
+
expect(scope.active).toBe(true)
|
|
253
|
+
scope.stop()
|
|
254
|
+
expect(scope.active).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('stop is idempotent', () => {
|
|
258
|
+
const scope = effectScope()
|
|
259
|
+
scope.stop()
|
|
260
|
+
expect(() => scope.stop()).not.toThrow()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('nested scopes are collected by parent', () => {
|
|
264
|
+
const parent = effectScope()
|
|
265
|
+
let childStopped = false
|
|
266
|
+
|
|
267
|
+
parent.run(() => {
|
|
268
|
+
const child = effectScope()
|
|
269
|
+
child.run(() => {
|
|
270
|
+
onScopeDispose(() => {
|
|
271
|
+
childStopped = true
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
parent.stop()
|
|
277
|
+
expect(childStopped).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('detached scopes are not collected by parent', () => {
|
|
281
|
+
const parent = effectScope()
|
|
282
|
+
let childStopped = false
|
|
283
|
+
let child: ReturnType<typeof effectScope> | undefined
|
|
284
|
+
|
|
285
|
+
parent.run(() => {
|
|
286
|
+
child = effectScope(true) // detached
|
|
287
|
+
child.run(() => {
|
|
288
|
+
onScopeDispose(() => {
|
|
289
|
+
childStopped = true
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
parent.stop()
|
|
295
|
+
expect(childStopped).toBe(false)
|
|
296
|
+
child!.stop()
|
|
297
|
+
expect(childStopped).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('getCurrentScope()', () => {
|
|
302
|
+
it('returns undefined outside of scope', () => {
|
|
303
|
+
expect(getCurrentScope()).toBeUndefined()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns current scope inside run', () => {
|
|
307
|
+
const scope = effectScope()
|
|
308
|
+
let captured: ReturnType<typeof getCurrentScope>
|
|
309
|
+
|
|
310
|
+
scope.run(() => {
|
|
311
|
+
captured = getCurrentScope()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(captured!).toBe(scope)
|
|
315
|
+
scope.stop()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('returns undefined after scope run completes', () => {
|
|
319
|
+
const scope = effectScope()
|
|
320
|
+
scope.run(() => {})
|
|
321
|
+
expect(getCurrentScope()).toBeUndefined()
|
|
322
|
+
scope.stop()
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('onScopeDispose()', () => {
|
|
327
|
+
it('registers cleanup on current scope', () => {
|
|
328
|
+
const scope = effectScope()
|
|
329
|
+
let disposed = false
|
|
330
|
+
|
|
331
|
+
scope.run(() => {
|
|
332
|
+
onScopeDispose(() => {
|
|
333
|
+
disposed = true
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
expect(disposed).toBe(false)
|
|
338
|
+
scope.stop()
|
|
339
|
+
expect(disposed).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('does nothing outside of scope', () => {
|
|
343
|
+
// Should not throw
|
|
344
|
+
expect(() => onScopeDispose(() => {})).not.toThrow()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('multiple disposers are all called', () => {
|
|
348
|
+
const scope = effectScope()
|
|
349
|
+
const calls: number[] = []
|
|
350
|
+
|
|
351
|
+
scope.run(() => {
|
|
352
|
+
onScopeDispose(() => calls.push(1))
|
|
353
|
+
onScopeDispose(() => calls.push(2))
|
|
354
|
+
onScopeDispose(() => calls.push(3))
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
scope.stop()
|
|
358
|
+
expect(calls).toEqual([1, 2, 3])
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
describe('watch() with array source', () => {
|
|
363
|
+
it('watches multiple refs', () => {
|
|
364
|
+
const a = ref(1)
|
|
365
|
+
const b = ref('hello')
|
|
366
|
+
const calls: Array<[unknown[], unknown[]]> = []
|
|
367
|
+
|
|
368
|
+
const stop = watch([a, b] as const, (newVals, oldVals) => {
|
|
369
|
+
calls.push([newVals as unknown[], oldVals as unknown[]])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
a.value = 2
|
|
373
|
+
expect(calls.length).toBeGreaterThanOrEqual(1)
|
|
374
|
+
expect(calls[calls.length - 1]![0]).toEqual([2, 'hello'])
|
|
375
|
+
|
|
376
|
+
b.value = 'world'
|
|
377
|
+
expect(calls[calls.length - 1]![0]).toEqual([2, 'world'])
|
|
378
|
+
|
|
379
|
+
stop()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('watches array with immediate', () => {
|
|
383
|
+
const a = ref(1)
|
|
384
|
+
const b = ref(2)
|
|
385
|
+
const calls: unknown[][] = []
|
|
386
|
+
|
|
387
|
+
const stop = watch(
|
|
388
|
+
[a, b],
|
|
389
|
+
(newVals) => {
|
|
390
|
+
calls.push(newVals as unknown[])
|
|
391
|
+
},
|
|
392
|
+
{ immediate: true },
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
expect(calls[0]).toEqual([1, 2])
|
|
396
|
+
stop()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('watches array of getter functions', () => {
|
|
400
|
+
const count = ref(0)
|
|
401
|
+
const calls: unknown[][] = []
|
|
402
|
+
|
|
403
|
+
const stop = watch(
|
|
404
|
+
[() => count.value, () => count.value * 2],
|
|
405
|
+
(newVals) => {
|
|
406
|
+
calls.push(newVals as unknown[])
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
count.value = 5
|
|
411
|
+
expect(calls[calls.length - 1]).toEqual([5, 10])
|
|
412
|
+
stop()
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('stop disposes array watcher', () => {
|
|
416
|
+
const a = ref(1)
|
|
417
|
+
const b = ref(2)
|
|
418
|
+
let callCount = 0
|
|
419
|
+
|
|
420
|
+
const stop = watch([a, b], () => {
|
|
421
|
+
callCount++
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
a.value = 10
|
|
425
|
+
const countAfterChange = callCount
|
|
426
|
+
|
|
427
|
+
stop()
|
|
428
|
+
a.value = 20
|
|
429
|
+
expect(callCount).toBe(countAfterChange)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('array watch is hook-indexed inside component', () => {
|
|
433
|
+
const a = ref(0)
|
|
434
|
+
const b = ref(0)
|
|
435
|
+
const ctx: RenderContext = {
|
|
436
|
+
hooks: [],
|
|
437
|
+
scheduleRerender: () => {},
|
|
438
|
+
pendingEffects: [],
|
|
439
|
+
pendingLayoutEffects: [],
|
|
440
|
+
unmounted: false,
|
|
441
|
+
unmountCallbacks: [],
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
beginRender(ctx)
|
|
445
|
+
const stop1 = watch([a, b], () => {})
|
|
446
|
+
endRender()
|
|
447
|
+
|
|
448
|
+
beginRender(ctx)
|
|
449
|
+
const stop2 = watch([a, b], () => {})
|
|
450
|
+
endRender()
|
|
451
|
+
|
|
452
|
+
expect(stop1).toBe(stop2)
|
|
453
|
+
stop1()
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe('onErrorCaptured()', () => {
|
|
458
|
+
it('is callable and stores handler in hook context', () => {
|
|
459
|
+
const { ctx } = withHookCtx(() => {
|
|
460
|
+
onErrorCaptured((_err) => true)
|
|
461
|
+
})
|
|
462
|
+
expect(ctx.hooks.length).toBe(1)
|
|
463
|
+
expect(typeof ctx.hooks[0]).toBe('function')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('is idempotent on re-render', () => {
|
|
467
|
+
const ctx: RenderContext = {
|
|
468
|
+
hooks: [],
|
|
469
|
+
scheduleRerender: () => {},
|
|
470
|
+
pendingEffects: [],
|
|
471
|
+
pendingLayoutEffects: [],
|
|
472
|
+
unmounted: false,
|
|
473
|
+
unmountCallbacks: [],
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
beginRender(ctx)
|
|
477
|
+
onErrorCaptured(() => true)
|
|
478
|
+
endRender()
|
|
479
|
+
|
|
480
|
+
const hooksBefore = ctx.hooks.length
|
|
481
|
+
|
|
482
|
+
beginRender(ctx)
|
|
483
|
+
onErrorCaptured(() => false) // Different fn, should not overwrite
|
|
484
|
+
endRender()
|
|
485
|
+
|
|
486
|
+
expect(ctx.hooks.length).toBe(hooksBefore)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('is a no-op outside component', () => {
|
|
490
|
+
// Should not throw
|
|
491
|
+
expect(() => onErrorCaptured(() => true)).not.toThrow()
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
describe('onRenderTracked()', () => {
|
|
496
|
+
it('is callable (no-op)', () => {
|
|
497
|
+
expect(() => onRenderTracked(() => {})).not.toThrow()
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
describe('onRenderTriggered()', () => {
|
|
502
|
+
it('is callable (no-op)', () => {
|
|
503
|
+
expect(() => onRenderTriggered(() => {})).not.toThrow()
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
describe('Teleport', () => {
|
|
508
|
+
it('renders children into target element', () => {
|
|
509
|
+
const target = document.createElement('div')
|
|
510
|
+
target.id = 'teleport-target'
|
|
511
|
+
document.body.appendChild(target)
|
|
512
|
+
|
|
513
|
+
const el = container()
|
|
514
|
+
const vnode = h(
|
|
515
|
+
Teleport as ComponentFn,
|
|
516
|
+
{ to: target } as Record<string, unknown>,
|
|
517
|
+
h('span', null, 'teleported'),
|
|
518
|
+
)
|
|
519
|
+
const unmount = mount(vnode, el)
|
|
520
|
+
|
|
521
|
+
// Portal should have rendered children
|
|
522
|
+
expect(target.textContent).toBe('teleported')
|
|
523
|
+
unmount()
|
|
524
|
+
target.remove()
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('renders children into target via string selector', () => {
|
|
528
|
+
const target = document.createElement('div')
|
|
529
|
+
target.id = 'teleport-string-target'
|
|
530
|
+
document.body.appendChild(target)
|
|
531
|
+
|
|
532
|
+
const result = Teleport({ to: '#teleport-string-target', children: 'hello' })
|
|
533
|
+
// Should return a Portal VNode (not null)
|
|
534
|
+
expect(result).not.toBeNull()
|
|
535
|
+
|
|
536
|
+
target.remove()
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('returns children when target is not found', () => {
|
|
540
|
+
const result = Teleport({ to: '#nonexistent-target', children: 'fallback' })
|
|
541
|
+
expect(result).toBe('fallback')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('returns null when no children and no target', () => {
|
|
545
|
+
const result = Teleport({ to: '#nonexistent-target' })
|
|
546
|
+
expect(result).toBeNull()
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
describe('KeepAlive', () => {
|
|
551
|
+
it('passes through children', () => {
|
|
552
|
+
const result = KeepAlive({ children: 'hello' })
|
|
553
|
+
expect(result).toBe('hello')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('returns null without children', () => {
|
|
557
|
+
const result = KeepAlive({})
|
|
558
|
+
expect(result).toBeNull()
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe('watchPostEffect()', () => {
|
|
563
|
+
it('works like watchEffect', () => {
|
|
564
|
+
const count = ref(0)
|
|
565
|
+
const values: number[] = []
|
|
566
|
+
|
|
567
|
+
const stop = watchPostEffect(() => {
|
|
568
|
+
values.push(count.value)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
count.value = 1
|
|
572
|
+
expect(values).toEqual([0, 1])
|
|
573
|
+
stop()
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
describe('watchSyncEffect()', () => {
|
|
578
|
+
it('works like watchEffect', () => {
|
|
579
|
+
const count = ref(0)
|
|
580
|
+
const values: number[] = []
|
|
581
|
+
|
|
582
|
+
const stop = watchSyncEffect(() => {
|
|
583
|
+
values.push(count.value)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
count.value = 1
|
|
587
|
+
expect(values).toEqual([0, 1])
|
|
588
|
+
stop()
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('customRef()', () => {
|
|
593
|
+
it('creates a ref with custom get/set', () => {
|
|
594
|
+
const r = customRef((track, trigger) => {
|
|
595
|
+
let value = 0
|
|
596
|
+
return {
|
|
597
|
+
get() {
|
|
598
|
+
track()
|
|
599
|
+
return value
|
|
600
|
+
},
|
|
601
|
+
set(v: number) {
|
|
602
|
+
value = v
|
|
603
|
+
trigger()
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
expect(isRef(r)).toBe(true)
|
|
609
|
+
expect(r.value).toBe(0)
|
|
610
|
+
|
|
611
|
+
r.value = 42
|
|
612
|
+
expect(r.value).toBe(42)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('customRef integrates with watchEffect', () => {
|
|
616
|
+
const r = customRef((track, trigger) => {
|
|
617
|
+
let value = 'initial'
|
|
618
|
+
return {
|
|
619
|
+
get() {
|
|
620
|
+
track()
|
|
621
|
+
return value
|
|
622
|
+
},
|
|
623
|
+
set(v: string) {
|
|
624
|
+
value = v
|
|
625
|
+
trigger()
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
const values: string[] = []
|
|
631
|
+
const stop = watchEffect(() => {
|
|
632
|
+
values.push(r.value)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
r.value = 'updated'
|
|
636
|
+
expect(values).toContain('updated')
|
|
637
|
+
stop()
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('customRef with debounce pattern', () => {
|
|
641
|
+
let triggerFn: () => void
|
|
642
|
+
const r = customRef((track, trigger) => {
|
|
643
|
+
triggerFn = trigger
|
|
644
|
+
let value = 0
|
|
645
|
+
return {
|
|
646
|
+
get() {
|
|
647
|
+
track()
|
|
648
|
+
return value
|
|
649
|
+
},
|
|
650
|
+
set(v: number) {
|
|
651
|
+
value = v
|
|
652
|
+
// Don't trigger immediately — simulate debounce
|
|
653
|
+
},
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
r.value = 10
|
|
658
|
+
expect(r.value).toBe(10) // Value is set
|
|
659
|
+
|
|
660
|
+
// Manually trigger
|
|
661
|
+
const values: number[] = []
|
|
662
|
+
const stop = watchEffect(() => {
|
|
663
|
+
values.push(r.value)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
triggerFn!()
|
|
667
|
+
expect(values.length).toBeGreaterThanOrEqual(1)
|
|
668
|
+
stop()
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
describe('version', () => {
|
|
673
|
+
it('is a string starting with 3', () => {
|
|
674
|
+
expect(typeof version).toBe('string')
|
|
675
|
+
expect(version).toMatch(/^3\./)
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
it('contains pyreon identifier', () => {
|
|
679
|
+
expect(version).toContain('pyreon')
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
describe('createApp().use()', () => {
|
|
684
|
+
it('installs a plugin', () => {
|
|
685
|
+
let installed = false
|
|
686
|
+
const plugin = {
|
|
687
|
+
install(_app: { mount: Function; use: Function; provide: Function }) {
|
|
688
|
+
installed = true
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const Comp = () => h('div', null, 'app')
|
|
693
|
+
const app = createApp(Comp)
|
|
694
|
+
app.use(plugin)
|
|
695
|
+
|
|
696
|
+
expect(installed).toBe(true)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('returns app for chaining', () => {
|
|
700
|
+
const plugin = { install() {} }
|
|
701
|
+
const Comp = () => h('div', null, 'app')
|
|
702
|
+
const app = createApp(Comp)
|
|
703
|
+
const result = app.use(plugin)
|
|
704
|
+
expect(result).toBe(app)
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('chains multiple plugins', () => {
|
|
708
|
+
const installed: string[] = []
|
|
709
|
+
const plugin1 = { install() { installed.push('p1') } }
|
|
710
|
+
const plugin2 = { install() { installed.push('p2') } }
|
|
711
|
+
|
|
712
|
+
const Comp = () => h('div', null, 'app')
|
|
713
|
+
createApp(Comp).use(plugin1).use(plugin2)
|
|
714
|
+
|
|
715
|
+
expect(installed).toEqual(['p1', 'p2'])
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
describe('createApp().provide()', () => {
|
|
720
|
+
it('returns app for chaining', () => {
|
|
721
|
+
const Comp = () => h('div', null, 'app')
|
|
722
|
+
const app = createApp(Comp)
|
|
723
|
+
const result = app.provide('key', 'value')
|
|
724
|
+
expect(result).toBe(app)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('provides value accessible via inject after mount', () => {
|
|
728
|
+
const key = Symbol('app-provide-test')
|
|
729
|
+
let injectedValue: string | undefined
|
|
730
|
+
|
|
731
|
+
const Comp = (() => {
|
|
732
|
+
injectedValue = inject(key, 'default') as string
|
|
733
|
+
return h('div', null, 'app')
|
|
734
|
+
}) as ComponentFn
|
|
735
|
+
|
|
736
|
+
const el = container()
|
|
737
|
+
const app = createApp(Comp)
|
|
738
|
+
app.provide(key, 'provided-value')
|
|
739
|
+
const unmount = app.mount(el)
|
|
740
|
+
|
|
741
|
+
expect(injectedValue).toBe('provided-value')
|
|
742
|
+
unmount()
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('chains provide and use', () => {
|
|
746
|
+
let installed = false
|
|
747
|
+
const plugin = { install() { installed = true } }
|
|
748
|
+
const Comp = () => h('div', null, 'app')
|
|
749
|
+
|
|
750
|
+
createApp(Comp)
|
|
751
|
+
.provide('key', 'value')
|
|
752
|
+
.use(plugin)
|
|
753
|
+
|
|
754
|
+
expect(installed).toBe(true)
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('toValue()', () => {
|
|
759
|
+
it('unwraps a ref', () => {
|
|
760
|
+
const r = ref(42)
|
|
761
|
+
expect(toValue(r)).toBe(42)
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it('calls a getter function', () => {
|
|
765
|
+
const getter = () => 'hello'
|
|
766
|
+
expect(toValue(getter)).toBe('hello')
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('returns a plain value as-is', () => {
|
|
770
|
+
expect(toValue(42)).toBe(42)
|
|
771
|
+
expect(toValue('str')).toBe('str')
|
|
772
|
+
expect(toValue(null)).toBe(null)
|
|
773
|
+
expect(toValue(undefined)).toBe(undefined)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('prefers ref over function (ref with value)', () => {
|
|
777
|
+
const r = ref(99)
|
|
778
|
+
expect(toValue(r)).toBe(99)
|
|
779
|
+
r.value = 100
|
|
780
|
+
expect(toValue(r)).toBe(100)
|
|
781
|
+
})
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
describe('inject() with factory default', () => {
|
|
785
|
+
it('calls factory when treatDefaultAsFactory is true', () => {
|
|
786
|
+
let factoryCalls = 0
|
|
787
|
+
const key = Symbol('factory-test')
|
|
788
|
+
const result = inject(key, () => {
|
|
789
|
+
factoryCalls++
|
|
790
|
+
return 'from-factory'
|
|
791
|
+
}, true)
|
|
792
|
+
expect(result).toBe('from-factory')
|
|
793
|
+
expect(factoryCalls).toBe(1)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('does not call factory when treatDefaultAsFactory is false', () => {
|
|
797
|
+
const key = Symbol('no-factory-test')
|
|
798
|
+
const factory = () => 'from-factory'
|
|
799
|
+
const result = inject(key, factory, false)
|
|
800
|
+
expect(result).toBe(factory) // returns the function itself
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('does not call factory when value is provided', () => {
|
|
804
|
+
const key = Symbol('provided-factory-test')
|
|
805
|
+
let factoryCalls = 0
|
|
806
|
+
|
|
807
|
+
const el = container()
|
|
808
|
+
let injectedValue: unknown
|
|
809
|
+
|
|
810
|
+
const Provider = (() => {
|
|
811
|
+
provide(key, 'provided')
|
|
812
|
+
const Child = (() => {
|
|
813
|
+
injectedValue = inject(key, () => {
|
|
814
|
+
factoryCalls++
|
|
815
|
+
return 'from-factory'
|
|
816
|
+
}, true)
|
|
817
|
+
return h('span', null, 'child')
|
|
818
|
+
}) as ComponentFn
|
|
819
|
+
return h(Child, null)
|
|
820
|
+
}) as ComponentFn
|
|
821
|
+
|
|
822
|
+
const unmount = mount(h(Provider, null), el)
|
|
823
|
+
expect(injectedValue).toBe('provided')
|
|
824
|
+
expect(factoryCalls).toBe(0)
|
|
825
|
+
unmount()
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it('returns undefined when no default and no provider', () => {
|
|
829
|
+
const key = Symbol('no-default-test')
|
|
830
|
+
const result = inject(key)
|
|
831
|
+
expect(result).toBeUndefined()
|
|
832
|
+
})
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
describe('shallowReadonly()', () => {
|
|
836
|
+
it('prevents mutation on top-level properties', () => {
|
|
837
|
+
const ro = shallowReadonly({ x: 1, nested: { y: 2 } })
|
|
838
|
+
expect(ro.x).toBe(1)
|
|
839
|
+
expect(() => {
|
|
840
|
+
;(ro as { x: number }).x = 2
|
|
841
|
+
}).toThrow('readonly')
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('allows mutation on nested objects', () => {
|
|
845
|
+
const ro = shallowReadonly({ nested: { y: 2 } })
|
|
846
|
+
// Nested objects are NOT wrapped — mutation is allowed
|
|
847
|
+
expect(() => {
|
|
848
|
+
;(ro.nested as { y: number }).y = 99
|
|
849
|
+
}).not.toThrow()
|
|
850
|
+
expect(ro.nested.y).toBe(99)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('prevents delete on top-level', () => {
|
|
854
|
+
const ro = shallowReadonly({ x: 1 }) as Record<string, unknown>
|
|
855
|
+
expect(() => {
|
|
856
|
+
delete ro.x
|
|
857
|
+
}).toThrow('Cannot delete')
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('reports isReadonly', () => {
|
|
861
|
+
const ro = shallowReadonly({ x: 1 })
|
|
862
|
+
expect(isReadonly(ro)).toBe(true)
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
it('nested does NOT report isReadonly (shallow)', () => {
|
|
866
|
+
const ro = shallowReadonly({ nested: { x: 1 } })
|
|
867
|
+
expect(isReadonly(ro.nested)).toBe(false)
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
describe('defineComponent() with setup context', () => {
|
|
872
|
+
it('passes SetupContext with emit to setup', () => {
|
|
873
|
+
let emittedArgs: unknown[] = []
|
|
874
|
+
const Comp = defineComponent({
|
|
875
|
+
setup(_props, ctx) {
|
|
876
|
+
ctx!.emit('click', 'arg1', 'arg2')
|
|
877
|
+
return () => h('div', null, 'test')
|
|
878
|
+
},
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
const el = container()
|
|
882
|
+
const unmount = mount(
|
|
883
|
+
h(Comp as ComponentFn, {
|
|
884
|
+
onClick: (...args: unknown[]) => {
|
|
885
|
+
emittedArgs = args
|
|
886
|
+
},
|
|
887
|
+
}),
|
|
888
|
+
el,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
expect(emittedArgs).toEqual(['arg1', 'arg2'])
|
|
892
|
+
unmount()
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
it('accepts name option', () => {
|
|
896
|
+
const Comp = defineComponent({
|
|
897
|
+
name: 'MyComponent',
|
|
898
|
+
setup() {
|
|
899
|
+
return () => h('div', null, 'named')
|
|
900
|
+
},
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
expect(Comp.name).toBe('MyComponent')
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
it('accepts props option (for documentation)', () => {
|
|
907
|
+
const Comp = defineComponent({
|
|
908
|
+
props: {
|
|
909
|
+
title: { type: String, required: true },
|
|
910
|
+
},
|
|
911
|
+
setup(props) {
|
|
912
|
+
return () => h('div', null, (props as Record<string, unknown>).title as string)
|
|
913
|
+
},
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
const el = container()
|
|
917
|
+
const unmount = mount(h(Comp as ComponentFn, { title: 'Hello' }), el)
|
|
918
|
+
expect(el.textContent).toBe('Hello')
|
|
919
|
+
unmount()
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('still accepts function shorthand', () => {
|
|
923
|
+
const Comp = defineComponent((props: { msg: string }) => {
|
|
924
|
+
return h('span', null, props.msg)
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
const el = container()
|
|
928
|
+
const unmount = mount(h(Comp as ComponentFn, { msg: 'hi' }), el)
|
|
929
|
+
expect(el.textContent).toBe('hi')
|
|
930
|
+
unmount()
|
|
931
|
+
})
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
describe('template ref with Vue ref', () => {
|
|
935
|
+
it('converts Vue ref to callback ref for DOM elements', async () => {
|
|
936
|
+
const { jsx: jsxFn } = await import('../jsx-runtime')
|
|
937
|
+
const elRef = ref<HTMLDivElement | null>(null)
|
|
938
|
+
|
|
939
|
+
// Simulate JSX runtime creating a DOM element with a Vue ref
|
|
940
|
+
const vnode = jsxFn('div', { ref: elRef, children: 'hello' })
|
|
941
|
+
|
|
942
|
+
// The ref prop should have been converted to a callback function
|
|
943
|
+
expect(typeof vnode.props.ref).toBe('function')
|
|
944
|
+
|
|
945
|
+
// Calling the callback ref should set the Vue ref's value
|
|
946
|
+
const div = document.createElement('div')
|
|
947
|
+
;(vnode.props.ref as (el: Element | null) => void)(div)
|
|
948
|
+
expect(elRef.value).toBe(div)
|
|
949
|
+
|
|
950
|
+
// Null on unmount
|
|
951
|
+
;(vnode.props.ref as (el: Element | null) => void)(null)
|
|
952
|
+
expect(elRef.value).toBeNull()
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it('callback ref still works unchanged', async () => {
|
|
956
|
+
const { jsx: jsxFn } = await import('../jsx-runtime')
|
|
957
|
+
const cbRef = (el: Element | null) => { void el }
|
|
958
|
+
|
|
959
|
+
const vnode = jsxFn('div', { ref: cbRef, children: 'hello' })
|
|
960
|
+
|
|
961
|
+
// Callback ref should pass through unchanged
|
|
962
|
+
expect(vnode.props.ref).toBe(cbRef)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('does not convert refs on component types', async () => {
|
|
966
|
+
const { jsx: jsxFn } = await import('../jsx-runtime')
|
|
967
|
+
const elRef = ref<unknown>(null)
|
|
968
|
+
const MyComp = () => h('div', null, 'comp')
|
|
969
|
+
|
|
970
|
+
const vnode = jsxFn(MyComp, { ref: elRef, children: 'hello' })
|
|
971
|
+
|
|
972
|
+
// Component refs should NOT be converted (they go through wrapCompatComponent)
|
|
973
|
+
// The ref should be on the component props
|
|
974
|
+
expect(vnode.props).toBeDefined()
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
describe('watch() flush option', () => {
|
|
979
|
+
it('accepts flush option without error', () => {
|
|
980
|
+
const count = ref(0)
|
|
981
|
+
const values: number[] = []
|
|
982
|
+
|
|
983
|
+
const stop = watch(count, (v) => {
|
|
984
|
+
values.push(v)
|
|
985
|
+
}, { flush: 'post' })
|
|
986
|
+
|
|
987
|
+
count.value = 1
|
|
988
|
+
expect(values).toContain(1)
|
|
989
|
+
stop()
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it('accepts flush sync option', () => {
|
|
993
|
+
const count = ref(0)
|
|
994
|
+
const stop = watch(count, () => {}, { flush: 'sync' })
|
|
995
|
+
stop()
|
|
996
|
+
})
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
describe('customRef() trigger forces update', () => {
|
|
1000
|
+
it('trigger causes watchEffect to re-run even without value change', () => {
|
|
1001
|
+
let triggerFn: () => void
|
|
1002
|
+
const r = customRef((track, trigger) => {
|
|
1003
|
+
triggerFn = trigger
|
|
1004
|
+
const fixedValue = 'constant'
|
|
1005
|
+
return {
|
|
1006
|
+
get() {
|
|
1007
|
+
track()
|
|
1008
|
+
return fixedValue
|
|
1009
|
+
},
|
|
1010
|
+
set() {
|
|
1011
|
+
// no-op — value never changes
|
|
1012
|
+
},
|
|
1013
|
+
}
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
const values: string[] = []
|
|
1017
|
+
const stop = watchEffect(() => {
|
|
1018
|
+
values.push(r.value)
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
expect(values).toEqual(['constant'])
|
|
1022
|
+
|
|
1023
|
+
// Trigger without changing value
|
|
1024
|
+
triggerFn!()
|
|
1025
|
+
expect(values.length).toBeGreaterThan(1)
|
|
1026
|
+
expect(values[1]).toBe('constant')
|
|
1027
|
+
|
|
1028
|
+
stop()
|
|
1029
|
+
})
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
describe('type exports', () => {
|
|
1033
|
+
it('exports version as string', () => {
|
|
1034
|
+
expect(typeof version).toBe('string')
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
// Type-level tests — these just verify the exports exist and are importable.
|
|
1038
|
+
// The actual type checking happens via `tsc --noEmit`.
|
|
1039
|
+
it('type exports are importable', async () => {
|
|
1040
|
+
const mod = await import('../index')
|
|
1041
|
+
// Verify runtime exports exist
|
|
1042
|
+
expect(mod.version).toBeDefined()
|
|
1043
|
+
expect(mod.isReactive).toBeDefined()
|
|
1044
|
+
expect(mod.isReadonly).toBeDefined()
|
|
1045
|
+
expect(mod.isProxy).toBeDefined()
|
|
1046
|
+
expect(mod.markRaw).toBeDefined()
|
|
1047
|
+
expect(mod.effectScope).toBeDefined()
|
|
1048
|
+
expect(mod.getCurrentScope).toBeDefined()
|
|
1049
|
+
expect(mod.onScopeDispose).toBeDefined()
|
|
1050
|
+
expect(mod.onErrorCaptured).toBeDefined()
|
|
1051
|
+
expect(mod.onRenderTracked).toBeDefined()
|
|
1052
|
+
expect(mod.onRenderTriggered).toBeDefined()
|
|
1053
|
+
expect(mod.Teleport).toBeDefined()
|
|
1054
|
+
expect(mod.KeepAlive).toBeDefined()
|
|
1055
|
+
expect(mod.watchPostEffect).toBeDefined()
|
|
1056
|
+
expect(mod.watchSyncEffect).toBeDefined()
|
|
1057
|
+
expect(mod.customRef).toBeDefined()
|
|
1058
|
+
expect(mod.toValue).toBeDefined()
|
|
1059
|
+
expect(mod.shallowReadonly).toBeDefined()
|
|
1060
|
+
expect(mod.defineAsyncComponent).toBeDefined()
|
|
1061
|
+
})
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
// ─── watchEffect onCleanup ──────────────────────────────────────────────────
|
|
1065
|
+
|
|
1066
|
+
describe('watchEffect onCleanup', () => {
|
|
1067
|
+
it('passes onCleanup to the callback', () => {
|
|
1068
|
+
let cleaned = false
|
|
1069
|
+
const r = ref(1)
|
|
1070
|
+
const stop = watchEffect((onCleanup) => {
|
|
1071
|
+
void r.value // track
|
|
1072
|
+
onCleanup(() => {
|
|
1073
|
+
cleaned = true
|
|
1074
|
+
})
|
|
1075
|
+
})
|
|
1076
|
+
expect(cleaned).toBe(false)
|
|
1077
|
+
r.value = 2 // triggers re-run → cleanup called before re-execution
|
|
1078
|
+
expect(cleaned).toBe(true)
|
|
1079
|
+
stop()
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('runs cleanup on stop', () => {
|
|
1083
|
+
let cleaned = false
|
|
1084
|
+
const stop = watchEffect((onCleanup) => {
|
|
1085
|
+
onCleanup(() => {
|
|
1086
|
+
cleaned = true
|
|
1087
|
+
})
|
|
1088
|
+
})
|
|
1089
|
+
expect(cleaned).toBe(false)
|
|
1090
|
+
stop()
|
|
1091
|
+
expect(cleaned).toBe(true)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
it('runs previous cleanup before re-execution', () => {
|
|
1095
|
+
const events: string[] = []
|
|
1096
|
+
const r = ref(0)
|
|
1097
|
+
const stop = watchEffect((onCleanup) => {
|
|
1098
|
+
const val = r.value
|
|
1099
|
+
events.push(`run:${val}`)
|
|
1100
|
+
onCleanup(() => events.push(`cleanup:${val}`))
|
|
1101
|
+
})
|
|
1102
|
+
expect(events).toEqual(['run:0'])
|
|
1103
|
+
r.value = 1
|
|
1104
|
+
expect(events).toEqual(['run:0', 'cleanup:0', 'run:1'])
|
|
1105
|
+
r.value = 2
|
|
1106
|
+
expect(events).toEqual(['run:0', 'cleanup:0', 'run:1', 'cleanup:1', 'run:2'])
|
|
1107
|
+
stop()
|
|
1108
|
+
// stop should also run the last cleanup
|
|
1109
|
+
expect(events).toEqual(['run:0', 'cleanup:0', 'run:1', 'cleanup:1', 'run:2', 'cleanup:2'])
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
it('works without onCleanup being called', () => {
|
|
1113
|
+
const r = ref(0)
|
|
1114
|
+
const values: number[] = []
|
|
1115
|
+
const stop = watchEffect(() => {
|
|
1116
|
+
values.push(r.value)
|
|
1117
|
+
})
|
|
1118
|
+
r.value = 1
|
|
1119
|
+
expect(values).toEqual([0, 1])
|
|
1120
|
+
stop()
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
it('works inside hook context', () => {
|
|
1124
|
+
let cleaned = false
|
|
1125
|
+
const r = ref(0)
|
|
1126
|
+
const { ctx } = withHookCtx(() => {
|
|
1127
|
+
watchEffect((onCleanup) => {
|
|
1128
|
+
void r.value
|
|
1129
|
+
onCleanup(() => {
|
|
1130
|
+
cleaned = true
|
|
1131
|
+
})
|
|
1132
|
+
})
|
|
1133
|
+
})
|
|
1134
|
+
expect(cleaned).toBe(false)
|
|
1135
|
+
r.value = 1
|
|
1136
|
+
expect(cleaned).toBe(true)
|
|
1137
|
+
// Dispose via unmount callbacks
|
|
1138
|
+
for (const cb of ctx.unmountCallbacks) cb()
|
|
1139
|
+
})
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
// ─── watchPostEffect / watchSyncEffect onCleanup ────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
describe('watchPostEffect onCleanup', () => {
|
|
1145
|
+
it('passes onCleanup to the callback', () => {
|
|
1146
|
+
let cleaned = false
|
|
1147
|
+
const r = ref(0)
|
|
1148
|
+
const stop = watchPostEffect((onCleanup) => {
|
|
1149
|
+
void r.value
|
|
1150
|
+
onCleanup(() => {
|
|
1151
|
+
cleaned = true
|
|
1152
|
+
})
|
|
1153
|
+
})
|
|
1154
|
+
r.value = 1
|
|
1155
|
+
expect(cleaned).toBe(true)
|
|
1156
|
+
stop()
|
|
1157
|
+
})
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
describe('watchSyncEffect onCleanup', () => {
|
|
1161
|
+
it('passes onCleanup to the callback', () => {
|
|
1162
|
+
let cleaned = false
|
|
1163
|
+
const r = ref(0)
|
|
1164
|
+
const stop = watchSyncEffect((onCleanup) => {
|
|
1165
|
+
void r.value
|
|
1166
|
+
onCleanup(() => {
|
|
1167
|
+
cleaned = true
|
|
1168
|
+
})
|
|
1169
|
+
})
|
|
1170
|
+
r.value = 1
|
|
1171
|
+
expect(cleaned).toBe(true)
|
|
1172
|
+
stop()
|
|
1173
|
+
})
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
// ─── watch onCleanup (3rd parameter) ────────────────────────────────────────
|
|
1177
|
+
|
|
1178
|
+
describe('watch onCleanup', () => {
|
|
1179
|
+
it('passes onCleanup as 3rd argument to callback', () => {
|
|
1180
|
+
let cleaned = false
|
|
1181
|
+
const r = ref(0)
|
|
1182
|
+
const stop = watch(r, (_newVal, _oldVal, onCleanup) => {
|
|
1183
|
+
onCleanup(() => {
|
|
1184
|
+
cleaned = true
|
|
1185
|
+
})
|
|
1186
|
+
})
|
|
1187
|
+
r.value = 1
|
|
1188
|
+
expect(cleaned).toBe(false) // first cb, no previous cleanup
|
|
1189
|
+
r.value = 2 // second change → cleanup from first callback runs
|
|
1190
|
+
expect(cleaned).toBe(true)
|
|
1191
|
+
stop()
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('runs cleanup on stop', () => {
|
|
1195
|
+
let cleaned = false
|
|
1196
|
+
const r = ref(0)
|
|
1197
|
+
const stop = watch(r, (_newVal, _oldVal, onCleanup) => {
|
|
1198
|
+
onCleanup(() => {
|
|
1199
|
+
cleaned = true
|
|
1200
|
+
})
|
|
1201
|
+
})
|
|
1202
|
+
r.value = 1 // trigger first callback
|
|
1203
|
+
expect(cleaned).toBe(false)
|
|
1204
|
+
stop()
|
|
1205
|
+
expect(cleaned).toBe(true)
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it('passes onCleanup to array watch callback', () => {
|
|
1209
|
+
let cleaned = false
|
|
1210
|
+
const a = ref(0)
|
|
1211
|
+
const b = ref(0)
|
|
1212
|
+
const stop = watch([a, b] as const, (_newVals, _oldVals, onCleanup) => {
|
|
1213
|
+
onCleanup(() => {
|
|
1214
|
+
cleaned = true
|
|
1215
|
+
})
|
|
1216
|
+
})
|
|
1217
|
+
a.value = 1
|
|
1218
|
+
expect(cleaned).toBe(false)
|
|
1219
|
+
b.value = 1 // second change → cleanup from first callback
|
|
1220
|
+
expect(cleaned).toBe(true)
|
|
1221
|
+
stop()
|
|
1222
|
+
})
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
// ─── defineAsyncComponent ───────────────────────────────────────────────────
|
|
1226
|
+
|
|
1227
|
+
describe('defineAsyncComponent', () => {
|
|
1228
|
+
it('loads component from loader', async () => {
|
|
1229
|
+
const MyComp: ComponentFn = () => 'loaded'
|
|
1230
|
+
const AsyncComp = defineAsyncComponent(async () => ({
|
|
1231
|
+
default: MyComp,
|
|
1232
|
+
}))
|
|
1233
|
+
expect(AsyncComp.__loading()).toBe(true)
|
|
1234
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1235
|
+
expect(AsyncComp.__loading()).toBe(false)
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
it('returns null while loading', () => {
|
|
1239
|
+
const AsyncComp = defineAsyncComponent(
|
|
1240
|
+
() => new Promise<{ default: ComponentFn }>(() => {}), // never resolves
|
|
1241
|
+
)
|
|
1242
|
+
const result = AsyncComp({})
|
|
1243
|
+
expect(result).toBeNull()
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
it('throws error on load failure', async () => {
|
|
1247
|
+
const AsyncComp = defineAsyncComponent(async () => {
|
|
1248
|
+
throw new Error('load failed')
|
|
1249
|
+
})
|
|
1250
|
+
// Trigger loading
|
|
1251
|
+
AsyncComp.__loading()
|
|
1252
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1253
|
+
expect(() => AsyncComp({})).toThrow('load failed')
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
it('accepts options object form', async () => {
|
|
1257
|
+
const MyComp: ComponentFn = () => 'options-loaded'
|
|
1258
|
+
const AsyncComp = defineAsyncComponent({
|
|
1259
|
+
loader: async () => ({ default: MyComp }),
|
|
1260
|
+
})
|
|
1261
|
+
expect(AsyncComp.__loading()).toBe(true)
|
|
1262
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1263
|
+
expect(AsyncComp.__loading()).toBe(false)
|
|
1264
|
+
})
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
// ─── defineComponent slots ──────────────────────────────────────────────────
|
|
1268
|
+
|
|
1269
|
+
describe('defineComponent slots', () => {
|
|
1270
|
+
it('setup receives slots.default from children', () => {
|
|
1271
|
+
let capturedSlots: Record<string, (() => unknown) | undefined> = {}
|
|
1272
|
+
const Comp = defineComponent({
|
|
1273
|
+
setup(_props, ctx) {
|
|
1274
|
+
capturedSlots = ctx!.slots as Record<string, (() => unknown) | undefined>
|
|
1275
|
+
return () => h('div', null, 'test')
|
|
1276
|
+
},
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
const el = container()
|
|
1280
|
+
const vnode = h(Comp as ComponentFn, null, 'child content')
|
|
1281
|
+
const unmount = mount(vnode, el)
|
|
1282
|
+
|
|
1283
|
+
expect(typeof capturedSlots.default).toBe('function')
|
|
1284
|
+
expect(capturedSlots.default!()).toBe('child content')
|
|
1285
|
+
unmount()
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
it('slots.default is undefined when no children', () => {
|
|
1289
|
+
let capturedSlots: Record<string, (() => unknown) | undefined> = {}
|
|
1290
|
+
const Comp = defineComponent({
|
|
1291
|
+
setup(_props, ctx) {
|
|
1292
|
+
capturedSlots = ctx!.slots as Record<string, (() => unknown) | undefined>
|
|
1293
|
+
return () => h('div', null, 'test')
|
|
1294
|
+
},
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
const el = container()
|
|
1298
|
+
const unmount = mount(h(Comp as ComponentFn, null), el)
|
|
1299
|
+
|
|
1300
|
+
expect(capturedSlots.default).toBeUndefined()
|
|
1301
|
+
unmount()
|
|
1302
|
+
})
|
|
1303
|
+
})
|