@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.
@@ -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
+ })