@pyreon/core 0.15.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 +198 -16
- package/lib/jsx-dev-runtime.js +45 -11
- package/lib/jsx-runtime.js +45 -11
- package/lib/types/index.d.ts +176 -6
- package/lib/types/jsx-dev-runtime.d.ts +16 -2
- package/lib/types/jsx-runtime.d.ts +16 -2
- package/package.json +2 -2
- package/src/defer.ts +241 -0
- package/src/for.ts +13 -1
- package/src/h.ts +16 -2
- package/src/index.ts +11 -1
- package/src/jsx-runtime.ts +46 -8
- package/src/manifest.ts +12 -4
- package/src/props.ts +59 -0
- package/src/tests/core.test.ts +1 -1
- package/src/tests/defer.test.ts +359 -0
- package/src/tests/extract-props-overloads.types.test.ts +135 -0
- package/src/tests/for.test.ts +23 -0
- package/src/tests/h.test.ts +21 -0
- package/src/tests/reactive-props.test.ts +71 -1
- package/src/types.ts +43 -2
package/src/manifest.ts
CHANGED
|
@@ -524,11 +524,19 @@ return wrapCompatComponent(type)(props)`,
|
|
|
524
524
|
{
|
|
525
525
|
name: 'ExtractProps',
|
|
526
526
|
kind: 'type',
|
|
527
|
-
signature:
|
|
527
|
+
signature:
|
|
528
|
+
'type ExtractProps<T> = /* matches up to 4 overloads, unions the props */ T extends ComponentFn<infer P> ? P : T',
|
|
528
529
|
summary:
|
|
529
|
-
|
|
530
|
-
example: `
|
|
531
|
-
|
|
530
|
+
"Extracts the props type from a `ComponentFn`. Passes through unchanged if `T` is not a `ComponentFn`. **Multi-overload aware** — matches up to 4 call signatures and produces the UNION of their first-argument types. Critical for multi-overload primitives (Iterator, List, Element) whose loosest overload is last; without overload-aware extraction, HOC wrapping (`rocketstyle()`, `attrs()`) silently downgraded their public prop surface. Single-overload functions still work — the union of 4 copies of the same props type dedupes back to the single shape.",
|
|
531
|
+
example: `function Iterator<T extends SimpleValue>(p: { data: T[]; valueName?: string }): VNodeChild
|
|
532
|
+
function Iterator<T extends ObjectValue>(p: { data: T[]; component: ComponentFn<T> }): VNodeChild
|
|
533
|
+
type Props = ExtractProps<typeof Iterator>
|
|
534
|
+
// → { data: SimpleValue[]; valueName?: string }
|
|
535
|
+
// | { data: ObjectValue[]; component: ComponentFn<ObjectValue> }`,
|
|
536
|
+
mistakes: [
|
|
537
|
+
'Assuming `ExtractProps<T>` returns only the LAST overload — pre-fix it did, post-fix it returns the UNION of up to 4 overloads. Functions with more than 4 overloads still drop the extras.',
|
|
538
|
+
'Using `T extends (props: infer P) => any ? P : never` directly in user code — that pattern captures only the LAST overload of a multi-overload function. Use `ExtractProps<T>` to get the full union.',
|
|
539
|
+
],
|
|
532
540
|
seeAlso: ['HigherOrderComponent'],
|
|
533
541
|
},
|
|
534
542
|
{
|
package/src/props.ts
CHANGED
|
@@ -140,6 +140,65 @@ export function _rp<T>(fn: () => T): () => T {
|
|
|
140
140
|
return fn
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Wrap a JSX spread source so its getter-shaped reactive props survive
|
|
145
|
+
* the JS-level object spread that esbuild's automatic JSX runtime emits
|
|
146
|
+
* for `<Comp {...source}>`.
|
|
147
|
+
*
|
|
148
|
+
* Without this wrapper, esbuild compiles `<Comp {...source}>` to
|
|
149
|
+
* `jsx(Comp, { ...source })` — and JS spread fires every getter on
|
|
150
|
+
* `source`, storing the resolved values as plain data properties. Any
|
|
151
|
+
* compiler-emitted reactive prop (`_rp(() => signal())` converted to a
|
|
152
|
+
* getter by `makeReactiveProps`) on `source` is collapsed to its
|
|
153
|
+
* initial value before the receiving component ever sees it.
|
|
154
|
+
*
|
|
155
|
+
* `_wrapSpread(source)` walks `source`'s own keys via `Reflect.ownKeys`
|
|
156
|
+
* (no getter firing) and returns a new object whose values are
|
|
157
|
+
* `_rp`-branded thunks `() => source[key]`. When `{ ..._wrapSpread(s) }`
|
|
158
|
+
* is spread by esbuild, the thunks are stored as plain data property
|
|
159
|
+
* values (no getters to fire), then `makeReactiveProps` in `mount.ts`
|
|
160
|
+
* converts the brands back into getters that lazily read from the
|
|
161
|
+
* original `source` — preserving the reactive subscription end-to-end.
|
|
162
|
+
*
|
|
163
|
+
* Fast path: when `source` has no getter descriptors, return the
|
|
164
|
+
* source object unchanged. JS spread will work correctly in that case
|
|
165
|
+
* because there's nothing reactive to preserve. Saves N thunk
|
|
166
|
+
* allocations per component render in the 99% case.
|
|
167
|
+
*
|
|
168
|
+
* Emitted by the compiler — not generally meant for hand-written code.
|
|
169
|
+
*/
|
|
170
|
+
export function _wrapSpread(
|
|
171
|
+
source: Record<string, unknown> | null | undefined,
|
|
172
|
+
): Record<string, unknown> | null | undefined {
|
|
173
|
+
if (!source || typeof source !== 'object') return source
|
|
174
|
+
const descriptors = Object.getOwnPropertyDescriptors(source)
|
|
175
|
+
let hasGetter = false
|
|
176
|
+
for (const k in descriptors) {
|
|
177
|
+
if (descriptors[k]!.get) {
|
|
178
|
+
hasGetter = true
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!hasGetter) return source
|
|
183
|
+
|
|
184
|
+
const result: Record<string, unknown> = {}
|
|
185
|
+
// Reflect.ownKeys covers symbol keys too — REACTIVE_PROP brands and
|
|
186
|
+
// other framework symbols must round-trip through the wrap.
|
|
187
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
188
|
+
const desc = descriptors[key as string]
|
|
189
|
+
if (!desc) continue
|
|
190
|
+
if (desc.get) {
|
|
191
|
+
const fn: () => unknown = () => source[key as string]
|
|
192
|
+
;(fn as unknown as Record<symbol, boolean>)[REACTIVE_PROP] = true
|
|
193
|
+
result[key as string] = fn
|
|
194
|
+
} else {
|
|
195
|
+
// Static data property — copy through as-is.
|
|
196
|
+
result[key as string] = desc.value
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return result
|
|
200
|
+
}
|
|
201
|
+
|
|
143
202
|
/**
|
|
144
203
|
* Convert compiler-emitted `_rp(() => expr)` prop values into getter properties.
|
|
145
204
|
*
|
package/src/tests/core.test.ts
CHANGED
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time type tests for `ExtractProps` multi-overload narrowing.
|
|
3
|
+
*
|
|
4
|
+
* Regression: pre-fix, `ExtractProps<T>` collapsed multi-overload functions
|
|
5
|
+
* to the LAST overload's props — TS's overload-resolution-against-conditional-
|
|
6
|
+
* types semantics. Multi-overload primitives (Iterator / List / Element in
|
|
7
|
+
* `@pyreon/elements`) silently downgraded their public prop surface to the
|
|
8
|
+
* loosest overload when wrapped through `rocketstyle()` / `attrs()`. The
|
|
9
|
+
* fix matches up to 4 call signatures via pattern matching and produces the
|
|
10
|
+
* UNION of every overload's first-argument type.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors vitus-labs PR #222. Kept in sync across the 4 copies in
|
|
13
|
+
* `@pyreon/core`, `@pyreon/elements`, `@pyreon/attrs`, and `@pyreon/rocketstyle`
|
|
14
|
+
* — the canonical reference test lives here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expectTypeOf, it } from 'vitest'
|
|
18
|
+
import type { ComponentFn, ExtractProps, VNodeChild } from '../index'
|
|
19
|
+
|
|
20
|
+
describe('ExtractProps — single-overload functions still work', () => {
|
|
21
|
+
it('extracts props from a ComponentFn<P>', () => {
|
|
22
|
+
type Greet = ComponentFn<{ name: string }>
|
|
23
|
+
expectTypeOf<ExtractProps<Greet>>().toEqualTypeOf<{ name: string }>()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('extracts props from a bare (props: P) => any signature', () => {
|
|
27
|
+
type Fn = (props: { count: number }) => string
|
|
28
|
+
expectTypeOf<ExtractProps<Fn>>().toEqualTypeOf<{ count: number }>()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('passes through a non-function shape unchanged', () => {
|
|
32
|
+
type Props = { id: string; value: number }
|
|
33
|
+
expectTypeOf<ExtractProps<Props>>().toEqualTypeOf<Props>()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('ExtractProps — multi-overload narrowing (load-bearing assertions)', () => {
|
|
38
|
+
it('unions both arms of a 2-overload function', () => {
|
|
39
|
+
interface TwoOverloads {
|
|
40
|
+
(props: { kind: 'a'; value: number }): VNodeChild
|
|
41
|
+
(props: { kind: 'b'; value: string }): VNodeChild
|
|
42
|
+
}
|
|
43
|
+
type Props = ExtractProps<TwoOverloads>
|
|
44
|
+
// Both shapes appear in the extracted union.
|
|
45
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
46
|
+
{ kind: 'a'; value: number } | { kind: 'b'; value: string }
|
|
47
|
+
>()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('unions all three arms of a 3-overload function (Iterator/List/Element shape)', () => {
|
|
51
|
+
interface ThreeOverloads {
|
|
52
|
+
(props: { mode: 'simple'; data: string[] }): VNodeChild
|
|
53
|
+
(props: { mode: 'object'; data: { id: number }[] }): VNodeChild
|
|
54
|
+
(props: { mode: 'children'; children: unknown }): VNodeChild
|
|
55
|
+
}
|
|
56
|
+
type Props = ExtractProps<ThreeOverloads>
|
|
57
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
58
|
+
| { mode: 'simple'; data: string[] }
|
|
59
|
+
| { mode: 'object'; data: { id: number }[] }
|
|
60
|
+
| { mode: 'children'; children: unknown }
|
|
61
|
+
>()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('unions all four arms of a 4-overload function', () => {
|
|
65
|
+
interface FourOverloads {
|
|
66
|
+
(props: { variant: 'a' }): VNodeChild
|
|
67
|
+
(props: { variant: 'b' }): VNodeChild
|
|
68
|
+
(props: { variant: 'c' }): VNodeChild
|
|
69
|
+
(props: { variant: 'd' }): VNodeChild
|
|
70
|
+
}
|
|
71
|
+
type Props = ExtractProps<FourOverloads>
|
|
72
|
+
expectTypeOf<Props>().toEqualTypeOf<
|
|
73
|
+
{ variant: 'a' } | { variant: 'b' } | { variant: 'c' } | { variant: 'd' }
|
|
74
|
+
>()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('ExtractProps — bisect-load-bearing: pre-fix shape would FAIL these', () => {
|
|
79
|
+
/**
|
|
80
|
+
* If `ExtractProps<T>` were reverted to `T extends ComponentFn<infer P> ? P : T`,
|
|
81
|
+
* each of these would extract only the LAST overload's props and the
|
|
82
|
+
* `toEqualTypeOf<union>` check would fail at compile time. This is the
|
|
83
|
+
* structural anchor — the load-bearing regression guard.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
it('a 2-overload function MUST extract BOTH arms (not just the last)', () => {
|
|
87
|
+
interface OverloadedComp {
|
|
88
|
+
(props: { mode: 'a'; valueA: number }): VNodeChild
|
|
89
|
+
(props: { mode: 'b'; valueB: string }): VNodeChild
|
|
90
|
+
}
|
|
91
|
+
// The first arm `{ mode: 'a'; valueA: number }` must be present in the
|
|
92
|
+
// union. Pre-fix, the conditional collapsed to just the LAST arm.
|
|
93
|
+
type Props = ExtractProps<OverloadedComp>
|
|
94
|
+
// Assignability check: both shapes must be assignable to the extracted type.
|
|
95
|
+
const a: Props = { mode: 'a', valueA: 1 }
|
|
96
|
+
const b: Props = { mode: 'b', valueB: 'x' }
|
|
97
|
+
void a
|
|
98
|
+
void b
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("a 3-overload Iterator-shaped surface MUST surface SimpleProps + ObjectProps + ChildrenProps", () => {
|
|
102
|
+
// Synthetic Iterator overload-shape — mirrors the real
|
|
103
|
+
// `@pyreon/elements` Iterator. The structural failure mode pre-fix:
|
|
104
|
+
// `ExtractProps<typeof Iterator>` returned just `ChildrenProps`, so any
|
|
105
|
+
// HOC wrapping (rocketstyle, attrs) lost the SimpleProps + ObjectProps
|
|
106
|
+
// surfaces from the public typed API.
|
|
107
|
+
type SimpleItem = ComponentFn<{ value: string }>
|
|
108
|
+
type ObjectItem = ComponentFn<{ id: number }>
|
|
109
|
+
interface IteratorLike {
|
|
110
|
+
<T extends string | number>(props: {
|
|
111
|
+
data: T[]
|
|
112
|
+
component: SimpleItem
|
|
113
|
+
valueName?: string
|
|
114
|
+
}): VNodeChild
|
|
115
|
+
<T extends { id: number }>(props: {
|
|
116
|
+
data: T[]
|
|
117
|
+
component: ObjectItem
|
|
118
|
+
}): VNodeChild
|
|
119
|
+
(props: { children: VNodeChild }): VNodeChild
|
|
120
|
+
}
|
|
121
|
+
type Props = ExtractProps<IteratorLike>
|
|
122
|
+
|
|
123
|
+
const noopSimple: SimpleItem = () => null
|
|
124
|
+
const noopObject: ObjectItem = () => null
|
|
125
|
+
// SimpleProps arm assignable:
|
|
126
|
+
const simple: Props = { data: ['a', 'b'], component: noopSimple, valueName: 'text' }
|
|
127
|
+
// ObjectProps arm assignable:
|
|
128
|
+
const obj: Props = { data: [{ id: 1 }], component: noopObject }
|
|
129
|
+
// ChildrenProps arm assignable:
|
|
130
|
+
const ch: Props = { children: null }
|
|
131
|
+
void simple
|
|
132
|
+
void obj
|
|
133
|
+
void ch
|
|
134
|
+
})
|
|
135
|
+
})
|
package/src/tests/for.test.ts
CHANGED
|
@@ -91,4 +91,27 @@ describe('For', () => {
|
|
|
91
91
|
expect((result as VNode).type).toBe('li')
|
|
92
92
|
expect((result as VNode).key).toBe(1)
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
// Regression: `ForProps.each` previously typed as `() => T[]` only.
|
|
96
|
+
// Users writing `<For each={items}>` (with `items: T[]` directly) hit
|
|
97
|
+
// a confusing TS error: `Type 'T[]' is not assignable to type
|
|
98
|
+
// '() => T[]'`. The runtime in `runtime-dom/src/mount.ts:144-147`
|
|
99
|
+
// already accepted both shapes — only the type was forcing the
|
|
100
|
+
// accessor form. Type now accepts `T[] | (() => T[])` so users with
|
|
101
|
+
// already-resolved arrays don't need to wrap them in a thunk just to
|
|
102
|
+
// satisfy the type.
|
|
103
|
+
test('each accepts T[] directly (not just () => T[])', () => {
|
|
104
|
+
// TypeScript-level test: this would not compile pre-fix.
|
|
105
|
+
const items = [1, 2, 3]
|
|
106
|
+
const childFn = (n: number): VNode => h('li', { key: n }, String(n))
|
|
107
|
+
const node = For<number>({ each: items, by: (n) => n, children: childFn })
|
|
108
|
+
expect(node.type).toBe(ForSymbol as unknown as string)
|
|
109
|
+
// Both shapes still work — function form continues to typecheck.
|
|
110
|
+
const node2 = For<number>({
|
|
111
|
+
each: () => items,
|
|
112
|
+
by: (n) => n,
|
|
113
|
+
children: childFn,
|
|
114
|
+
})
|
|
115
|
+
expect(node2.type).toBe(ForSymbol as unknown as string)
|
|
116
|
+
})
|
|
94
117
|
})
|
package/src/tests/h.test.ts
CHANGED
|
@@ -176,6 +176,27 @@ describe('h() — VNode creation', () => {
|
|
|
176
176
|
expect(outer.children).toHaveLength(2)
|
|
177
177
|
expect((outer.children[0] as VNode).type).toBe(Fragment)
|
|
178
178
|
})
|
|
179
|
+
|
|
180
|
+
// Regression: pre-fix, `Fragment` was `Symbol('Pyreon.Fragment')` — a
|
|
181
|
+
// fresh symbol per module evaluation. When `h.ts` got bundled into BOTH
|
|
182
|
+
// `lib/index.js` AND `lib/jsx-runtime.js` (each a separate published
|
|
183
|
+
// entry point), each bundle created a DISTINCT Symbol identity. JSX
|
|
184
|
+
// `<>` compiles to `jsx(Fragment, ...)` referring to jsx-runtime's
|
|
185
|
+
// Fragment; `runtime-server` checks `vnode.type === Fragment` against
|
|
186
|
+
// `@pyreon/core`'s main-entry Fragment. The two never matched →
|
|
187
|
+
// fell through to `renderElement` → tried to stringify the Symbol →
|
|
188
|
+
// SSG crashed with `TypeError: Cannot convert a Symbol value to
|
|
189
|
+
// a string`.
|
|
190
|
+
//
|
|
191
|
+
// Fix: use `Symbol.for('Pyreon.Fragment')` — the global registry keys
|
|
192
|
+
// by string, so all bundles inlining h.ts share the same identity.
|
|
193
|
+
//
|
|
194
|
+
// This test asserts the global-registry contract: Fragment IS
|
|
195
|
+
// retrievable from the registry. Bisect-verifiable: reverting h.ts to
|
|
196
|
+
// `Symbol(...)` makes this fail.
|
|
197
|
+
test('Fragment uses Symbol.for() for cross-bundle identity stability', () => {
|
|
198
|
+
expect(Fragment).toBe(Symbol.for('Pyreon.Fragment'))
|
|
199
|
+
})
|
|
179
200
|
})
|
|
180
201
|
})
|
|
181
202
|
|