@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/src/manifest.ts CHANGED
@@ -524,11 +524,19 @@ return wrapCompatComponent(type)(props)`,
524
524
  {
525
525
  name: 'ExtractProps',
526
526
  kind: 'type',
527
- signature: 'type ExtractProps<T> = T extends ComponentFn<infer P> ? P : T',
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
- 'Extracts the props type from a `ComponentFn`. Passes through unchanged if `T` is not a `ComponentFn`. Useful for HOC patterns and typed wrappers that need to infer the wrapped component\'s prop interface.',
530
- example: `const Greet: ComponentFn<{ name: string }> = ({ name }) => <h1>{name}</h1>
531
- type Props = ExtractProps<typeof Greet> // { name: string }`,
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
  *
@@ -982,7 +982,7 @@ describe('lifecycle hooks', () => {
982
982
 
983
983
  describe('edge cases', () => {
984
984
  test('h() with empty children array', () => {
985
- const node = h('div', null, ...[])
985
+ const node = h('div', null, )
986
986
  expect(node.children).toEqual([])
987
987
  })
988
988
 
@@ -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
+ })
@@ -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
  })
@@ -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