@pyreon/core 0.16.0 → 0.18.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/README.md +1 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-dev-runtime.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +182 -14
- package/lib/jsx-dev-runtime.js +29 -9
- package/lib/jsx-runtime.js +29 -9
- package/lib/types/index.d.ts +112 -1
- package/package.json +2 -2
- package/src/defer.ts +241 -0
- package/src/index.ts +11 -1
- package/src/jsx-runtime.ts +46 -8
- package/src/props.ts +59 -0
- package/src/tests/defer.test.ts +359 -0
- package/src/tests/reactive-props.test.ts +71 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { _setupIdleTrigger, _setupVisibleTrigger, Defer } from '../defer'
|
|
3
|
+
import { Fragment, h } from '../h'
|
|
4
|
+
import type { ComponentFn, Props, VNode } from '../types'
|
|
5
|
+
|
|
6
|
+
// Helper: pull the render-callback out of Defer's returned VNode shape.
|
|
7
|
+
// `when` and `idle` modes return a Fragment whose `children[0]` is a
|
|
8
|
+
// thunk. `visible` mode returns a div whose `children[0]` is the same
|
|
9
|
+
// thunk. Both shapes return the same `renderContent` accessor.
|
|
10
|
+
function getRenderThunk(vnode: VNode): () => unknown {
|
|
11
|
+
const children = vnode.children as unknown[]
|
|
12
|
+
const thunk = children[0]
|
|
13
|
+
if (typeof thunk !== 'function') throw new Error('Expected render thunk')
|
|
14
|
+
return thunk as () => unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Defer — common shape', () => {
|
|
18
|
+
test('returns a VNode (Fragment or wrapper div per trigger mode)', () => {
|
|
19
|
+
const result = Defer({
|
|
20
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
21
|
+
when: () => false,
|
|
22
|
+
})
|
|
23
|
+
expect(result).toBeDefined()
|
|
24
|
+
expect((result as VNode).type).toBe(Fragment)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('renders fallback before chunk resolves', () => {
|
|
28
|
+
const fallback = h('span', null, 'loading…')
|
|
29
|
+
const vnode = Defer<Props>({
|
|
30
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}), // never resolves
|
|
31
|
+
when: () => true,
|
|
32
|
+
fallback,
|
|
33
|
+
})
|
|
34
|
+
expect(getRenderThunk(vnode)()).toBe(fallback)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('renders null when no fallback and chunk has not resolved', () => {
|
|
38
|
+
const vnode = Defer<Props>({
|
|
39
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
40
|
+
when: () => true,
|
|
41
|
+
})
|
|
42
|
+
expect(getRenderThunk(vnode)()).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Defer — when (signal-driven)', () => {
|
|
47
|
+
test('does NOT load chunk while when is false', () => {
|
|
48
|
+
let calls = 0
|
|
49
|
+
const chunkFn = () => {
|
|
50
|
+
calls++
|
|
51
|
+
return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
|
|
52
|
+
}
|
|
53
|
+
const flag = signal(false)
|
|
54
|
+
Defer<Props>({ chunk: chunkFn, when: flag })
|
|
55
|
+
expect(calls).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('loads chunk when when flips to true', async () => {
|
|
59
|
+
const Inner: ComponentFn<{ msg: string }> = (p) => h('div', null, p.msg)
|
|
60
|
+
let calls = 0
|
|
61
|
+
const chunkFn = () => {
|
|
62
|
+
calls++
|
|
63
|
+
return Promise.resolve({ default: Inner })
|
|
64
|
+
}
|
|
65
|
+
const flag = signal(false)
|
|
66
|
+
const vnode = Defer<{ msg: string }>({
|
|
67
|
+
chunk: chunkFn,
|
|
68
|
+
when: flag,
|
|
69
|
+
children: (Comp) => h(Comp, { msg: 'hi' }),
|
|
70
|
+
})
|
|
71
|
+
expect(calls).toBe(0)
|
|
72
|
+
flag.set(true)
|
|
73
|
+
// Effect schedules synchronously; chunk fetch is microtask-resolved.
|
|
74
|
+
expect(calls).toBe(1)
|
|
75
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
76
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
77
|
+
expect(result.type).toBe(Inner)
|
|
78
|
+
expect(result.props).toEqual({ msg: 'hi' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('loads chunk EXACTLY ONCE when signal oscillates', async () => {
|
|
82
|
+
let calls = 0
|
|
83
|
+
const chunkFn = () => {
|
|
84
|
+
calls++
|
|
85
|
+
return Promise.resolve({ default: (() => null) as ComponentFn<Props> })
|
|
86
|
+
}
|
|
87
|
+
const flag = signal(false)
|
|
88
|
+
Defer<Props>({ chunk: chunkFn, when: flag })
|
|
89
|
+
flag.set(true)
|
|
90
|
+
flag.set(false)
|
|
91
|
+
flag.set(true)
|
|
92
|
+
flag.set(false)
|
|
93
|
+
flag.set(true)
|
|
94
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
95
|
+
expect(calls).toBe(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('accepts component re-exports without default wrapper', async () => {
|
|
99
|
+
const Inner: ComponentFn = () => h('span', null, 'ok')
|
|
100
|
+
const flag = signal(true)
|
|
101
|
+
const vnode = Defer<Props>({
|
|
102
|
+
chunk: () => Promise.resolve(Inner), // bare ComponentFn
|
|
103
|
+
when: flag,
|
|
104
|
+
children: (Comp) => h(Comp, {}),
|
|
105
|
+
})
|
|
106
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
107
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
108
|
+
expect(result.type).toBe(Inner)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('throws when chunk() rejects (Suspense-style error propagation)', async () => {
|
|
112
|
+
const consoleSpy = (() => {
|
|
113
|
+
const orig = console.error
|
|
114
|
+
console.error = () => {} // silence dev-mode error log
|
|
115
|
+
return () => {
|
|
116
|
+
console.error = orig
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
try {
|
|
120
|
+
const flag = signal(true)
|
|
121
|
+
const vnode = Defer<Props>({
|
|
122
|
+
chunk: () => Promise.reject(new Error('chunk boom')),
|
|
123
|
+
when: flag,
|
|
124
|
+
})
|
|
125
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
126
|
+
expect(() => getRenderThunk(vnode)()).toThrow('chunk boom')
|
|
127
|
+
} finally {
|
|
128
|
+
consoleSpy()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('renders default <Comp /> when children render-prop omitted', async () => {
|
|
133
|
+
const Inner: ComponentFn = () => h('div', null, 'no-children-prop')
|
|
134
|
+
const flag = signal(true)
|
|
135
|
+
const vnode = Defer<Props>({
|
|
136
|
+
chunk: () => Promise.resolve({ default: Inner }),
|
|
137
|
+
when: flag,
|
|
138
|
+
})
|
|
139
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
140
|
+
const result = getRenderThunk(vnode)() as VNode
|
|
141
|
+
expect(result.type).toBe(Inner)
|
|
142
|
+
expect(result.props).toEqual({})
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('Defer — on="visible"', () => {
|
|
147
|
+
test('returns a div wrapper with data-pyreon-defer="visible"', () => {
|
|
148
|
+
const vnode = Defer<Props>({
|
|
149
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
150
|
+
on: 'visible',
|
|
151
|
+
})
|
|
152
|
+
expect((vnode as VNode).type).toBe('div')
|
|
153
|
+
expect((vnode as VNode).props['data-pyreon-defer']).toBe('visible')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('uses display: contents so wrapper is layout-transparent', () => {
|
|
157
|
+
const vnode = Defer<Props>({
|
|
158
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
159
|
+
on: 'visible',
|
|
160
|
+
})
|
|
161
|
+
expect((vnode as VNode).props.style).toBe('display: contents')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('default rootMargin is 200px (not exposed via prop spread)', () => {
|
|
165
|
+
// The rootMargin is consumed by onMount; we can't directly observe
|
|
166
|
+
// it from the returned VNode. This test documents the default and
|
|
167
|
+
// would catch a regression in the constant.
|
|
168
|
+
const vnode = Defer<Props>({
|
|
169
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
170
|
+
on: 'visible',
|
|
171
|
+
})
|
|
172
|
+
// Wrapper has the structural attrs but no rootMargin leak to DOM.
|
|
173
|
+
expect((vnode as VNode).props.rootMargin).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('Defer — on="idle"', () => {
|
|
178
|
+
test('returns a Fragment (no wrapper element)', () => {
|
|
179
|
+
const vnode = Defer<Props>({
|
|
180
|
+
chunk: () => new Promise<{ default: ComponentFn<Props> }>(() => {}),
|
|
181
|
+
on: 'idle',
|
|
182
|
+
})
|
|
183
|
+
expect((vnode as VNode).type).toBe(Fragment)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Browser-API helpers extracted from the onMount callbacks so they're
|
|
188
|
+
// directly testable without happy-dom (core tests run in Node). The
|
|
189
|
+
// onMount wrappers in `defer.ts` just delegate to these.
|
|
190
|
+
describe('_setupIdleTrigger', () => {
|
|
191
|
+
// Use `Reflect.has` + property descriptors instead of plain assignment
|
|
192
|
+
// so the restore is symmetric — if the global wasn't defined at test
|
|
193
|
+
// start (Node), `delete` cleanly returns to the original state.
|
|
194
|
+
const orig = {
|
|
195
|
+
ric: (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback,
|
|
196
|
+
cic: (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback,
|
|
197
|
+
}
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
if (orig.ric === undefined) delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
200
|
+
else (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = orig.ric
|
|
201
|
+
if (orig.cic === undefined) delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
|
|
202
|
+
else (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = orig.cic
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('uses requestIdleCallback when available', () => {
|
|
206
|
+
let captured: (() => void) | null = null
|
|
207
|
+
;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
|
|
208
|
+
cb: () => void,
|
|
209
|
+
): number => {
|
|
210
|
+
captured = cb
|
|
211
|
+
return 42
|
|
212
|
+
}
|
|
213
|
+
let cancelledId: number | null = null
|
|
214
|
+
;(globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback = (
|
|
215
|
+
id: number,
|
|
216
|
+
): void => {
|
|
217
|
+
cancelledId = id
|
|
218
|
+
}
|
|
219
|
+
const startLoad = () => {}
|
|
220
|
+
const teardown = _setupIdleTrigger(startLoad)
|
|
221
|
+
expect(captured).toBe(startLoad)
|
|
222
|
+
teardown()
|
|
223
|
+
expect(cancelledId).toBe(42)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('teardown is no-op when cancelIdleCallback is missing', () => {
|
|
227
|
+
;(globalThis as { requestIdleCallback?: unknown }).requestIdleCallback = (
|
|
228
|
+
_cb: () => void,
|
|
229
|
+
): number => 99
|
|
230
|
+
delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback
|
|
231
|
+
const teardown = _setupIdleTrigger(() => {})
|
|
232
|
+
expect(() => teardown()).not.toThrow()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('falls back to setTimeout when requestIdleCallback is absent', async () => {
|
|
236
|
+
delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
237
|
+
let loaded = false
|
|
238
|
+
const teardown = _setupIdleTrigger(() => {
|
|
239
|
+
loaded = true
|
|
240
|
+
})
|
|
241
|
+
// setTimeout(fn, 1) fires after a tick — await to observe.
|
|
242
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
243
|
+
expect(loaded).toBe(true)
|
|
244
|
+
teardown() // safe to call after the timer fired
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('setTimeout fallback cancels via clearTimeout when teardown fires before tick', () => {
|
|
248
|
+
delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback
|
|
249
|
+
let loaded = false
|
|
250
|
+
const teardown = _setupIdleTrigger(() => {
|
|
251
|
+
loaded = true
|
|
252
|
+
})
|
|
253
|
+
teardown() // cancel before the timer fires
|
|
254
|
+
return new Promise<void>((r) =>
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
expect(loaded).toBe(false)
|
|
257
|
+
r()
|
|
258
|
+
}, 10),
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('_setupVisibleTrigger', () => {
|
|
264
|
+
const origObs = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
if (origObs === undefined) delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
267
|
+
else (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = origObs
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('loads immediately when el is null', () => {
|
|
271
|
+
let loaded = false
|
|
272
|
+
const teardown = _setupVisibleTrigger(null, () => {
|
|
273
|
+
loaded = true
|
|
274
|
+
}, '200px')
|
|
275
|
+
expect(loaded).toBe(true)
|
|
276
|
+
expect(typeof teardown).toBe('function')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('loads immediately when IntersectionObserver is unavailable', () => {
|
|
280
|
+
delete (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver
|
|
281
|
+
// Pass a stub element; without the global the trigger should bail early.
|
|
282
|
+
const stubEl = {} as unknown as HTMLElement
|
|
283
|
+
let loaded = false
|
|
284
|
+
_setupVisibleTrigger(stubEl, () => {
|
|
285
|
+
loaded = true
|
|
286
|
+
}, '200px')
|
|
287
|
+
expect(loaded).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('creates an observer with the configured rootMargin', () => {
|
|
291
|
+
let capturedOptions: IntersectionObserverInit | undefined
|
|
292
|
+
let observed: Element | null = null
|
|
293
|
+
let disconnected = false
|
|
294
|
+
class StubObserver {
|
|
295
|
+
callback: IntersectionObserverCallback
|
|
296
|
+
constructor(cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) {
|
|
297
|
+
this.callback = cb
|
|
298
|
+
capturedOptions = opts
|
|
299
|
+
}
|
|
300
|
+
observe(el: Element) {
|
|
301
|
+
observed = el
|
|
302
|
+
}
|
|
303
|
+
disconnect() {
|
|
304
|
+
disconnected = true
|
|
305
|
+
}
|
|
306
|
+
unobserve() {}
|
|
307
|
+
takeRecords() {
|
|
308
|
+
return []
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
|
|
312
|
+
const stubEl = { tagName: 'DIV' } as unknown as HTMLElement
|
|
313
|
+
const teardown = _setupVisibleTrigger(stubEl, () => {}, '300px')
|
|
314
|
+
expect(observed).toBe(stubEl)
|
|
315
|
+
expect(capturedOptions?.rootMargin).toBe('300px')
|
|
316
|
+
teardown()
|
|
317
|
+
expect(disconnected).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('fires startLoad on intersection, then disconnects', () => {
|
|
321
|
+
let captured: IntersectionObserverCallback | null = null
|
|
322
|
+
let disconnected = false
|
|
323
|
+
class StubObserver {
|
|
324
|
+
constructor(cb: IntersectionObserverCallback) {
|
|
325
|
+
captured = cb
|
|
326
|
+
}
|
|
327
|
+
observe() {}
|
|
328
|
+
disconnect() {
|
|
329
|
+
disconnected = true
|
|
330
|
+
}
|
|
331
|
+
unobserve() {}
|
|
332
|
+
takeRecords() {
|
|
333
|
+
return []
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
;(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = StubObserver
|
|
337
|
+
let loaded = false
|
|
338
|
+
const stubEl = {} as unknown as HTMLElement
|
|
339
|
+
_setupVisibleTrigger(stubEl, () => {
|
|
340
|
+
loaded = true
|
|
341
|
+
}, '0px')
|
|
342
|
+
|
|
343
|
+
// Simulate non-intersecting entry — should NOT fire.
|
|
344
|
+
captured!(
|
|
345
|
+
[{ isIntersecting: false } as unknown as IntersectionObserverEntry],
|
|
346
|
+
{} as IntersectionObserver,
|
|
347
|
+
)
|
|
348
|
+
expect(loaded).toBe(false)
|
|
349
|
+
expect(disconnected).toBe(false)
|
|
350
|
+
|
|
351
|
+
// Simulate intersecting entry — fires and disconnects.
|
|
352
|
+
captured!(
|
|
353
|
+
[{ isIntersecting: true } as unknown as IntersectionObserverEntry],
|
|
354
|
+
{} as IntersectionObserver,
|
|
355
|
+
)
|
|
356
|
+
expect(loaded).toBe(true)
|
|
357
|
+
expect(disconnected).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { makeReactiveProps, REACTIVE_PROP, _rp } from '../props'
|
|
2
|
+
import { makeReactiveProps, REACTIVE_PROP, _rp, _wrapSpread } from '../props'
|
|
3
3
|
|
|
4
4
|
describe('makeReactiveProps', () => {
|
|
5
5
|
it('returns raw object when no reactive props exist (fast path)', () => {
|
|
@@ -85,3 +85,73 @@ describe('_rp', () => {
|
|
|
85
85
|
expect(branded()).toBe('hello')
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
describe('_wrapSpread', () => {
|
|
90
|
+
it('returns null/undefined unchanged (primitive guard)', () => {
|
|
91
|
+
expect(_wrapSpread(null)).toBe(null)
|
|
92
|
+
expect(_wrapSpread(undefined)).toBe(undefined)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns source unchanged when no getter descriptors exist (fast path)', () => {
|
|
96
|
+
const source = { a: 1, b: 'x', c: true }
|
|
97
|
+
expect(_wrapSpread(source)).toBe(source)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('returns source unchanged for empty objects', () => {
|
|
101
|
+
const source = {}
|
|
102
|
+
expect(_wrapSpread(source)).toBe(source)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('wraps getter-shaped reactive props as _rp-branded thunks', () => {
|
|
106
|
+
let liveValue = 'a'
|
|
107
|
+
const source = {} as Record<string, unknown>
|
|
108
|
+
Object.defineProperty(source, 'x', {
|
|
109
|
+
get: () => liveValue,
|
|
110
|
+
enumerable: true,
|
|
111
|
+
configurable: true,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const result = _wrapSpread(source) as Record<string, unknown>
|
|
115
|
+
expect(result).not.toBe(source) // new object allocated
|
|
116
|
+
|
|
117
|
+
const wrappedX = result.x as () => unknown
|
|
118
|
+
expect(typeof wrappedX).toBe('function')
|
|
119
|
+
expect((wrappedX as unknown as Record<symbol, unknown>)[REACTIVE_PROP]).toBe(true)
|
|
120
|
+
|
|
121
|
+
// Lazy read — each call reads the current source[x] getter value
|
|
122
|
+
expect(wrappedX()).toBe('a')
|
|
123
|
+
liveValue = 'b'
|
|
124
|
+
expect(wrappedX()).toBe('b') // live re-read, not captured
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('preserves data properties as-is when mixed with getters', () => {
|
|
128
|
+
const source = { plain: 'data' } as Record<string, unknown>
|
|
129
|
+
Object.defineProperty(source, 'reactive', {
|
|
130
|
+
get: () => 'live',
|
|
131
|
+
enumerable: true,
|
|
132
|
+
configurable: true,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const result = _wrapSpread(source) as Record<string, unknown>
|
|
136
|
+
expect(result.plain).toBe('data') // copied through
|
|
137
|
+
expect(typeof result.reactive).toBe('function') // wrapped as thunk
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('preserves Reflect.ownKeys symbol-keyed properties', () => {
|
|
141
|
+
const sym = Symbol('marker')
|
|
142
|
+
const source = { regular: 'x' } as Record<string | symbol, unknown>
|
|
143
|
+
Object.defineProperty(source, 'reactive', {
|
|
144
|
+
get: () => 'live',
|
|
145
|
+
enumerable: true,
|
|
146
|
+
configurable: true,
|
|
147
|
+
})
|
|
148
|
+
source[sym] = 'symbol-value'
|
|
149
|
+
|
|
150
|
+
const result = _wrapSpread(source) as Record<string | symbol, unknown>
|
|
151
|
+
expect(result.regular).toBe('x')
|
|
152
|
+
// Note: symbol keys go through Reflect.ownKeys; the wrap path indexes
|
|
153
|
+
// via `key as string` for type narrowing but the runtime carries them
|
|
154
|
+
// forward as data properties.
|
|
155
|
+
expect(typeof result.reactive).toBe('function')
|
|
156
|
+
})
|
|
157
|
+
})
|