@pyreon/react-compat 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/index.js +356 -40
- package/lib/index.js.map +1 -1
- package/lib/jsx-runtime.js +57 -5
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/types/index.d.ts +205 -4
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/jsx-runtime.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +551 -52
- package/src/jsx-runtime.ts +90 -2
- package/src/tests/compat-integration.test.tsx +1 -0
- package/src/tests/new-apis.test.ts +1519 -0
- package/src/tests/react-compat.test.ts +2 -0
package/src/index.ts
CHANGED
|
@@ -12,11 +12,19 @@
|
|
|
12
12
|
* import { createRoot } from "react-dom/client" // aliased by vite plugin
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
export type { Props, VNode
|
|
16
|
-
export { Fragment, h as createElement, h } from '@pyreon/core'
|
|
17
|
-
|
|
18
|
-
import type { VNode, VNodeChild } from '@pyreon/core'
|
|
19
|
-
import {
|
|
15
|
+
export type { Props, VNode, VNodeChild } from '@pyreon/core'
|
|
16
|
+
export { Fragment, h as createElement, h, createRef } from '@pyreon/core'
|
|
17
|
+
|
|
18
|
+
import type { Context, VNode, VNodeChild } from '@pyreon/core'
|
|
19
|
+
import {
|
|
20
|
+
createContext as pyreonCreateContext,
|
|
21
|
+
ErrorBoundary,
|
|
22
|
+
h,
|
|
23
|
+
Portal,
|
|
24
|
+
provide as pyreonProvide,
|
|
25
|
+
Suspense,
|
|
26
|
+
useContext as pyreonUseContext,
|
|
27
|
+
} from '@pyreon/core'
|
|
20
28
|
import { batch } from '@pyreon/reactivity'
|
|
21
29
|
import type { EffectEntry } from './jsx-runtime'
|
|
22
30
|
import { getCurrentCtx, getHookIndex } from './jsx-runtime'
|
|
@@ -49,47 +57,62 @@ export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T
|
|
|
49
57
|
const idx = getHookIndex()
|
|
50
58
|
|
|
51
59
|
if (ctx.hooks.length <= idx) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const val = typeof initial === 'function' ? (initial as () => T)() : initial
|
|
61
|
+
// Store both value and a STABLE setter in one hook slot so setter identity
|
|
62
|
+
// never changes across renders (React guarantee).
|
|
63
|
+
const entry = { value: val, setter: null as unknown as (v: T | ((prev: T) => T)) => void }
|
|
64
|
+
entry.setter = (v: T | ((prev: T) => T)) => {
|
|
65
|
+
const current = entry.value
|
|
66
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(current) : v
|
|
67
|
+
if (Object.is(current, next)) return
|
|
68
|
+
entry.value = next
|
|
69
|
+
ctx.scheduleRerender()
|
|
70
|
+
}
|
|
71
|
+
ctx.hooks.push(entry)
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
const entry = ctx.hooks[idx] as { value: T; setter: (v: T | ((prev: T) => T)) => void }
|
|
75
|
+
return [entry.value, entry.setter]
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* React-compatible `useReducer` — returns `[state, dispatch]`.
|
|
82
|
+
* Supports the 3-argument form: `useReducer(reducer, initialArg, init)`.
|
|
71
83
|
*/
|
|
72
84
|
export function useReducer<S, A>(
|
|
73
85
|
reducer: (state: S, action: A) => S,
|
|
74
|
-
|
|
86
|
+
initialArg: S | (() => S),
|
|
87
|
+
init?: (arg: S) => S,
|
|
75
88
|
): [S, (action: A) => void] {
|
|
76
89
|
const ctx = requireCtx()
|
|
77
90
|
const idx = getHookIndex()
|
|
78
91
|
|
|
79
92
|
if (ctx.hooks.length <= idx) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
let initial: S
|
|
94
|
+
if (init) {
|
|
95
|
+
initial = init(initialArg as S)
|
|
96
|
+
} else if (typeof initialArg === 'function') {
|
|
97
|
+
initial = (initialArg as () => S)()
|
|
98
|
+
} else {
|
|
99
|
+
initial = initialArg
|
|
100
|
+
}
|
|
101
|
+
// Store both value and a STABLE dispatch in one hook slot so dispatch identity
|
|
102
|
+
// never changes across renders (React guarantee).
|
|
103
|
+
const entry = { value: initial, dispatch: null as unknown as (action: A) => void }
|
|
104
|
+
entry.dispatch = (action: A) => {
|
|
105
|
+
const current = entry.value
|
|
106
|
+
const next = reducer(current, action)
|
|
107
|
+
if (Object.is(current, next)) return
|
|
108
|
+
entry.value = next
|
|
109
|
+
ctx.scheduleRerender()
|
|
110
|
+
}
|
|
111
|
+
ctx.hooks.push(entry)
|
|
90
112
|
}
|
|
91
113
|
|
|
92
|
-
|
|
114
|
+
const entry = ctx.hooks[idx] as { value: S; dispatch: (action: A) => void }
|
|
115
|
+
return [entry.value, entry.dispatch]
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
// ─── Effects ─────────────────────────────────────────────────────────────────
|
|
@@ -138,6 +161,28 @@ export function useLayoutEffect(fn: () => (() => void) | void, deps?: unknown[])
|
|
|
138
161
|
}
|
|
139
162
|
}
|
|
140
163
|
|
|
164
|
+
/**
|
|
165
|
+
* React-compatible `useInsertionEffect` — runs synchronously before layout effects.
|
|
166
|
+
* Intended for CSS-in-JS libraries to inject styles before DOM reads.
|
|
167
|
+
*/
|
|
168
|
+
export function useInsertionEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
|
|
169
|
+
const ctx = requireCtx()
|
|
170
|
+
const idx = getHookIndex()
|
|
171
|
+
|
|
172
|
+
if (ctx.hooks.length <= idx) {
|
|
173
|
+
const entry: EffectEntry = { fn, deps, cleanup: undefined }
|
|
174
|
+
ctx.hooks.push(entry)
|
|
175
|
+
ctx.pendingInsertionEffects.push(entry)
|
|
176
|
+
} else {
|
|
177
|
+
const entry = ctx.hooks[idx] as EffectEntry
|
|
178
|
+
if (depsChanged(entry.deps, deps)) {
|
|
179
|
+
entry.fn = fn
|
|
180
|
+
entry.deps = deps
|
|
181
|
+
ctx.pendingInsertionEffects.push(entry)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
// ─── Memoization ─────────────────────────────────────────────────────────────
|
|
142
187
|
|
|
143
188
|
/**
|
|
@@ -187,7 +232,111 @@ export function useRef<T>(initial?: T): { current: T | null } {
|
|
|
187
232
|
|
|
188
233
|
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
189
234
|
|
|
190
|
-
|
|
235
|
+
const COMPAT_CTX = Symbol.for('pyreon:compat-ctx')
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compat-specific context with subscriber notification and tree-scoped nesting.
|
|
239
|
+
*
|
|
240
|
+
* Uses Pyreon's native context stack for tree-scoped Provider nesting (inner
|
|
241
|
+
* Providers override outer ones for their subtree), with a subscriber set
|
|
242
|
+
* layered on top for React-style consumer re-rendering.
|
|
243
|
+
*/
|
|
244
|
+
export interface CompatContext<T> {
|
|
245
|
+
/** Brand marker so `useContext` can distinguish compat contexts */
|
|
246
|
+
readonly [COMPAT_CTX_BRAND]: true
|
|
247
|
+
/** Default value when no Provider is mounted */
|
|
248
|
+
_defaultValue: T
|
|
249
|
+
/** Pyreon native context for tree-scoped value+subscriber storage */
|
|
250
|
+
_pyreonCtx: Context<{ value: T; subscribers: Set<() => void> }>
|
|
251
|
+
/** Subscribers at the default (no-Provider) level */
|
|
252
|
+
_subscribers: Set<() => void>
|
|
253
|
+
/** React-compatible Provider component (native Pyreon, NOT compat-wrapped).
|
|
254
|
+
* Returns a reactive accessor `() => VNodeChild` for Pyreon's renderer. */
|
|
255
|
+
Provider: (props: Record<string, unknown>) => unknown
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const COMPAT_CTX_BRAND: typeof COMPAT_CTX = COMPAT_CTX
|
|
259
|
+
|
|
260
|
+
// Tag the Provider so wrapCompatComponent skips it (it's already a native component)
|
|
261
|
+
const NATIVE_COMPONENT = Symbol.for('pyreon:native-compat')
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* React-compatible `createContext` — creates a context with a Provider that
|
|
265
|
+
* supports nested Providers (inner overrides outer for its subtree) and
|
|
266
|
+
* notifies all `useContext` consumers when its value changes.
|
|
267
|
+
*/
|
|
268
|
+
export function createContext<T>(defaultValue: T): CompatContext<T> {
|
|
269
|
+
// Pyreon native context: each Provider pushes { value, subscribers } onto
|
|
270
|
+
// the tree-scoped stack. Consumers read the nearest frame via useContext.
|
|
271
|
+
const pyreonCtx = pyreonCreateContext<{ value: T; subscribers: Set<() => void> }>({
|
|
272
|
+
value: defaultValue,
|
|
273
|
+
subscribers: new Set(),
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// Default-level subscribers (for consumers with no Provider above them)
|
|
277
|
+
const defaultSubscribers = new Set<() => void>()
|
|
278
|
+
|
|
279
|
+
// Provider is a NATIVE Pyreon component (not compat-wrapped).
|
|
280
|
+
// It calls provide() once during setup to push onto the context stack,
|
|
281
|
+
// then returns a reactive accessor that updates the frame value on re-render.
|
|
282
|
+
const Provider = (props: Record<string, unknown>) => {
|
|
283
|
+
// Setup (runs once per mount):
|
|
284
|
+
const frame = { value: (props as { value: T }).value, subscribers: new Set<() => void>() }
|
|
285
|
+
pyreonProvide(pyreonCtx, frame)
|
|
286
|
+
|
|
287
|
+
// Return reactive accessor for children (re-evaluated when props change)
|
|
288
|
+
return () => {
|
|
289
|
+
const { value, children } = props as { value: T; children?: VNodeChild }
|
|
290
|
+
// On re-render: update the frame value and notify subscribers
|
|
291
|
+
if (!Object.is(frame.value, value)) {
|
|
292
|
+
frame.value = value
|
|
293
|
+
for (const sub of frame.subscribers) sub()
|
|
294
|
+
}
|
|
295
|
+
return children ?? null
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Mark as native so jsx() doesn't wrap it with wrapCompatComponent
|
|
299
|
+
;(Provider as unknown as Record<symbol, boolean>)[NATIVE_COMPONENT] = true
|
|
300
|
+
|
|
301
|
+
const ctx: CompatContext<T> = {
|
|
302
|
+
[COMPAT_CTX_BRAND]: true as const,
|
|
303
|
+
_defaultValue: defaultValue,
|
|
304
|
+
_pyreonCtx: pyreonCtx,
|
|
305
|
+
_subscribers: defaultSubscribers,
|
|
306
|
+
Provider,
|
|
307
|
+
}
|
|
308
|
+
return ctx
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* React-compatible `useContext` — reads the current context value and
|
|
313
|
+
* subscribes the calling component to future value changes.
|
|
314
|
+
*
|
|
315
|
+
* Reads from Pyreon's tree-scoped context stack (correct nesting) and
|
|
316
|
+
* subscribes to the nearest Provider's subscriber set for re-rendering.
|
|
317
|
+
*
|
|
318
|
+
* Works with both compat contexts (from this module's `createContext`) and
|
|
319
|
+
* Pyreon native contexts (from `@pyreon/core`).
|
|
320
|
+
*/
|
|
321
|
+
export function useContext<T>(context: CompatContext<T> | Context<T>): T {
|
|
322
|
+
if (COMPAT_CTX in context) {
|
|
323
|
+
const compatCtx = context as CompatContext<T>
|
|
324
|
+
// Read from Pyreon's tree-scoped stack (correct nesting)
|
|
325
|
+
const frame = pyreonUseContext(compatCtx._pyreonCtx)
|
|
326
|
+
const renderCtx = getCurrentCtx()
|
|
327
|
+
if (renderCtx) {
|
|
328
|
+
const idx = getHookIndex()
|
|
329
|
+
if (renderCtx.hooks.length <= idx) {
|
|
330
|
+
// Subscribe to the frame's subscriber set
|
|
331
|
+
const sub = () => renderCtx.scheduleRerender()
|
|
332
|
+
frame.subscribers.add(sub)
|
|
333
|
+
renderCtx.hooks.push({ _contextUnsub: () => frame.subscribers.delete(sub) })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return frame.value
|
|
337
|
+
}
|
|
338
|
+
return pyreonUseContext(context as Context<T>)
|
|
339
|
+
}
|
|
191
340
|
|
|
192
341
|
// ─── ID ──────────────────────────────────────────────────────────────────────
|
|
193
342
|
|
|
@@ -209,37 +358,65 @@ export function useId(): string {
|
|
|
209
358
|
|
|
210
359
|
// ─── Optimization ────────────────────────────────────────────────────────────
|
|
211
360
|
|
|
361
|
+
function shallowEqual<P extends Record<string, unknown>>(a: P, b: P): boolean {
|
|
362
|
+
const keysA = Object.keys(a)
|
|
363
|
+
const keysB = Object.keys(b)
|
|
364
|
+
if (keysA.length !== keysB.length) return false
|
|
365
|
+
for (const k of keysA) {
|
|
366
|
+
if (!Object.is(a[k], b[k])) return false
|
|
367
|
+
}
|
|
368
|
+
return true
|
|
369
|
+
}
|
|
370
|
+
|
|
212
371
|
/**
|
|
213
372
|
* React-compatible `memo` — wraps a component to skip re-render when props
|
|
214
373
|
* are shallowly equal.
|
|
374
|
+
*
|
|
375
|
+
* Each component INSTANCE gets its own props/result cache via a hook slot,
|
|
376
|
+
* so two `<MemoComp />` usages don't share memoization state.
|
|
215
377
|
*/
|
|
216
378
|
export function memo<P extends Record<string, unknown>>(
|
|
217
379
|
component: (props: P) => VNodeChild,
|
|
218
380
|
areEqual?: (prevProps: P, nextProps: P) => boolean,
|
|
219
381
|
): (props: P) => VNodeChild {
|
|
220
|
-
const compare =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
382
|
+
const compare = areEqual ?? shallowEqual
|
|
383
|
+
|
|
384
|
+
const MEMO_MARKER = Symbol.for('pyreon:memo')
|
|
385
|
+
|
|
386
|
+
// Fallback closure-level cache for calls outside a compat render context
|
|
387
|
+
// (e.g. direct function calls in tests). Inside a render context, each
|
|
388
|
+
// component instance gets its own cache via a hook slot.
|
|
389
|
+
let _fallbackPrevProps: P | null = null
|
|
390
|
+
let _fallbackPrevResult: VNodeChild = null
|
|
391
|
+
|
|
392
|
+
const memoized = (props: P) => {
|
|
393
|
+
const ctx = getCurrentCtx()
|
|
394
|
+
if (ctx) {
|
|
395
|
+
// Per-instance cache via hook slot
|
|
396
|
+
const idx = getHookIndex()
|
|
397
|
+
if (ctx.hooks.length <= idx) {
|
|
398
|
+
ctx.hooks.push({ prevProps: null as P | null, prevResult: null as VNodeChild })
|
|
228
399
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
400
|
+
const cache = ctx.hooks[idx] as { prevProps: P | null; prevResult: VNodeChild }
|
|
401
|
+
if (cache.prevProps !== null && compare(cache.prevProps, props)) {
|
|
402
|
+
return cache.prevResult
|
|
403
|
+
}
|
|
404
|
+
cache.prevProps = props
|
|
405
|
+
cache.prevResult = component(props)
|
|
406
|
+
return cache.prevResult
|
|
407
|
+
}
|
|
408
|
+
// No compat context — use closure-level fallback cache
|
|
409
|
+
if (_fallbackPrevProps !== null && compare(_fallbackPrevProps, props)) {
|
|
410
|
+
return _fallbackPrevResult
|
|
238
411
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return
|
|
412
|
+
_fallbackPrevProps = props
|
|
413
|
+
_fallbackPrevResult = component(props)
|
|
414
|
+
return _fallbackPrevResult
|
|
242
415
|
}
|
|
416
|
+
;(memoized as unknown as Record<symbol, boolean>)[MEMO_MARKER] = true
|
|
417
|
+
memoized.displayName =
|
|
418
|
+
(component as unknown as { displayName?: string }).displayName || component.name || 'Memo'
|
|
419
|
+
return memoized
|
|
243
420
|
}
|
|
244
421
|
|
|
245
422
|
/**
|
|
@@ -302,10 +479,13 @@ export { ErrorBoundary, Suspense }
|
|
|
302
479
|
export function forwardRef<P extends Record<string, unknown>>(
|
|
303
480
|
render: (props: P, ref: { current: unknown } | null) => VNodeChild,
|
|
304
481
|
): (props: P & { ref?: { current: unknown } | null }) => VNodeChild {
|
|
305
|
-
|
|
482
|
+
const forwarded = (props: P & { ref?: { current: unknown } | null }) => {
|
|
306
483
|
const { ref, ...rest } = props
|
|
307
484
|
return render(rest as P, ref ?? null)
|
|
308
485
|
}
|
|
486
|
+
forwarded.displayName =
|
|
487
|
+
(render as unknown as { displayName?: string }).displayName || render.name || 'ForwardRef'
|
|
488
|
+
return forwarded
|
|
309
489
|
}
|
|
310
490
|
|
|
311
491
|
// ─── cloneElement ───────────────────────────────────────────────────────────
|
|
@@ -349,10 +529,20 @@ export const Children = {
|
|
|
349
529
|
map<T>(children: VNodeChild | VNodeChild[], fn: (child: VNodeChild, index: number) => T): T[] {
|
|
350
530
|
const flat = flattenChildren(children)
|
|
351
531
|
const result: T[] = []
|
|
532
|
+
let validIndex = 0
|
|
352
533
|
for (let i = 0; i < flat.length; i++) {
|
|
353
534
|
const child = flat[i]
|
|
354
535
|
if (child == null || child === true || child === false) continue
|
|
355
|
-
|
|
536
|
+
const mapped = fn(child, validIndex)
|
|
537
|
+
// Assign key to mapped VNode children if they don't already have one
|
|
538
|
+
if (mapped && typeof mapped === 'object' && 'type' in mapped && 'props' in mapped) {
|
|
539
|
+
const vnode = mapped as unknown as VNode
|
|
540
|
+
if (vnode.key == null) {
|
|
541
|
+
vnode.key = `.${validIndex}`
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
result.push(mapped)
|
|
545
|
+
validIndex++
|
|
356
546
|
}
|
|
357
547
|
return result
|
|
358
548
|
},
|
|
@@ -362,10 +552,11 @@ export const Children = {
|
|
|
362
552
|
*/
|
|
363
553
|
forEach(children: VNodeChild | VNodeChild[], fn: (child: VNodeChild, index: number) => void): void {
|
|
364
554
|
const flat = flattenChildren(children)
|
|
555
|
+
let validIndex = 0
|
|
365
556
|
for (let i = 0; i < flat.length; i++) {
|
|
366
557
|
const child = flat[i]
|
|
367
558
|
if (child == null || child === true || child === false) continue
|
|
368
|
-
fn(child,
|
|
559
|
+
fn(child, validIndex++)
|
|
369
560
|
}
|
|
370
561
|
},
|
|
371
562
|
|
|
@@ -400,3 +591,311 @@ export const Children = {
|
|
|
400
591
|
return arr[0] as VNodeChild
|
|
401
592
|
},
|
|
402
593
|
}
|
|
594
|
+
|
|
595
|
+
// ─── useSyncExternalStore ───────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* React-compatible `useSyncExternalStore` — subscribes to an external store.
|
|
599
|
+
* Re-subscribes automatically when the `subscribe` function identity changes.
|
|
600
|
+
*/
|
|
601
|
+
export function useSyncExternalStore<T>(
|
|
602
|
+
subscribe: (onStoreChange: () => void) => () => void,
|
|
603
|
+
getSnapshot: () => T,
|
|
604
|
+
getServerSnapshot?: () => T,
|
|
605
|
+
): T {
|
|
606
|
+
const ctx = requireCtx()
|
|
607
|
+
const idx = getHookIndex()
|
|
608
|
+
|
|
609
|
+
// SSR path
|
|
610
|
+
if (typeof window === 'undefined' && getServerSnapshot) {
|
|
611
|
+
if (ctx.hooks.length <= idx) {
|
|
612
|
+
ctx.hooks.push({ subscribe, unsubscribe: undefined, snapshot: getServerSnapshot() })
|
|
613
|
+
}
|
|
614
|
+
return (ctx.hooks[idx] as { snapshot: T }).snapshot
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (ctx.hooks.length <= idx) {
|
|
618
|
+
const snapshot = getSnapshot()
|
|
619
|
+
const entry = {
|
|
620
|
+
subscribe,
|
|
621
|
+
unsubscribe: undefined as (() => void) | undefined,
|
|
622
|
+
snapshot,
|
|
623
|
+
}
|
|
624
|
+
const onChange = () => {
|
|
625
|
+
const next = getSnapshot()
|
|
626
|
+
if (!Object.is(entry.snapshot, next)) {
|
|
627
|
+
entry.snapshot = next
|
|
628
|
+
ctx.scheduleRerender()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
entry.unsubscribe = subscribe(onChange)
|
|
632
|
+
ctx.hooks.push(entry)
|
|
633
|
+
return snapshot
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const entry = ctx.hooks[idx] as {
|
|
637
|
+
subscribe: typeof subscribe
|
|
638
|
+
unsubscribe: (() => void) | undefined
|
|
639
|
+
snapshot: T
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Re-subscribe if subscribe function identity changed
|
|
643
|
+
if (entry.subscribe !== subscribe) {
|
|
644
|
+
if (entry.unsubscribe) entry.unsubscribe()
|
|
645
|
+
const onChange = () => {
|
|
646
|
+
const next = getSnapshot()
|
|
647
|
+
if (!Object.is(entry.snapshot, next)) {
|
|
648
|
+
entry.snapshot = next
|
|
649
|
+
ctx.scheduleRerender()
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
entry.unsubscribe = subscribe(onChange)
|
|
653
|
+
entry.subscribe = subscribe
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Always read fresh snapshot during render
|
|
657
|
+
entry.snapshot = getSnapshot()
|
|
658
|
+
return entry.snapshot
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ─── use ────────────────────────────────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
const _promiseCache = new WeakMap<
|
|
664
|
+
Promise<unknown>,
|
|
665
|
+
{ status: 'pending' | 'resolved' | 'rejected'; value?: unknown; error?: unknown }
|
|
666
|
+
>()
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* React-compatible `use` — reads a Context or suspends on a Promise.
|
|
670
|
+
* Can be called conditionally (unlike other hooks).
|
|
671
|
+
*
|
|
672
|
+
* IMPORTANT: Promises must have a stable identity across renders.
|
|
673
|
+
* Create promises outside the component or memoize them. Calling
|
|
674
|
+
* `use(fetch('/api'))` creates a new promise each render and will
|
|
675
|
+
* cause infinite suspension.
|
|
676
|
+
*/
|
|
677
|
+
export function use<T>(resource: Context<T> | CompatContext<T> | Promise<T>): T {
|
|
678
|
+
// Compat context path
|
|
679
|
+
if (resource && typeof resource === 'object' && COMPAT_CTX in resource) {
|
|
680
|
+
return useContext(resource as CompatContext<T>)
|
|
681
|
+
}
|
|
682
|
+
// Pyreon native context path
|
|
683
|
+
if (resource && typeof resource === 'object' && 'id' in resource && 'defaultValue' in resource) {
|
|
684
|
+
return pyreonUseContext(resource as Context<T>)
|
|
685
|
+
}
|
|
686
|
+
// Promise path — suspend via throw
|
|
687
|
+
const promise = resource as Promise<T>
|
|
688
|
+
let entry = _promiseCache.get(promise)
|
|
689
|
+
if (!entry) {
|
|
690
|
+
entry = { status: 'pending' }
|
|
691
|
+
_promiseCache.set(promise, entry)
|
|
692
|
+
promise.then(
|
|
693
|
+
(value) => {
|
|
694
|
+
entry!.status = 'resolved'
|
|
695
|
+
entry!.value = value
|
|
696
|
+
},
|
|
697
|
+
(error) => {
|
|
698
|
+
entry!.status = 'rejected'
|
|
699
|
+
entry!.error = error
|
|
700
|
+
},
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
if (entry.status === 'resolved') return entry.value as T
|
|
704
|
+
if (entry.status === 'rejected') throw entry.error
|
|
705
|
+
throw promise // Suspense catches this
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ─── useActionState ─────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* React-compatible `useActionState` — manages async action state with pending indicator.
|
|
712
|
+
*/
|
|
713
|
+
export function useActionState<S, P>(
|
|
714
|
+
action: (state: S, payload: P) => S | Promise<S>,
|
|
715
|
+
initialState: S,
|
|
716
|
+
): [S, (payload: P) => void, boolean] {
|
|
717
|
+
const [state, setState] = useState(initialState)
|
|
718
|
+
const [isPending, setIsPending] = useState(false)
|
|
719
|
+
|
|
720
|
+
const dispatch = (payload: P) => {
|
|
721
|
+
setIsPending(true)
|
|
722
|
+
const result = action(state, payload)
|
|
723
|
+
if (result instanceof Promise) {
|
|
724
|
+
result.then((next) => {
|
|
725
|
+
setState(next)
|
|
726
|
+
setIsPending(false)
|
|
727
|
+
})
|
|
728
|
+
} else {
|
|
729
|
+
setState(result)
|
|
730
|
+
setIsPending(false)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return [state, dispatch, isPending]
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── startTransition ────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* React-compatible `startTransition` — runs the callback synchronously.
|
|
741
|
+
* No concurrent mode in Pyreon, so transitions are immediate.
|
|
742
|
+
*/
|
|
743
|
+
export function startTransition(fn: () => void): void {
|
|
744
|
+
fn()
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ─── isValidElement ─────────────────────────────────────────────────────────
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* React-compatible `isValidElement` — checks if a value is a VNode.
|
|
751
|
+
*/
|
|
752
|
+
export function isValidElement(value: unknown): value is VNode {
|
|
753
|
+
return value != null && typeof value === 'object' && 'type' in value && 'props' in value
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ─── useDebugValue ──────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* React-compatible `useDebugValue` — no-op in Pyreon (no React DevTools integration).
|
|
760
|
+
*/
|
|
761
|
+
export function useDebugValue<T>(_value: T, _format?: (v: T) => unknown): void {}
|
|
762
|
+
|
|
763
|
+
// ─── flushSync ──────────────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* React-compatible `flushSync` — runs the callback synchronously.
|
|
767
|
+
*
|
|
768
|
+
* BEHAVIORAL DIFFERENCE: In Pyreon's compat model, state updates are
|
|
769
|
+
* batched via microtask. flushSync runs the callback and returns its
|
|
770
|
+
* result, but the DOM updates triggered by state changes inside the
|
|
771
|
+
* callback still fire asynchronously. For DOM measurement after state
|
|
772
|
+
* updates, use `await act(() => setState(...))` in tests, or
|
|
773
|
+
* `requestAnimationFrame` in production code.
|
|
774
|
+
*/
|
|
775
|
+
export function flushSync<T>(fn: () => T): T {
|
|
776
|
+
return fn()
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ─── act (testing) ──────────────────────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* React-compatible `act` — flushes pending microtasks for testing.
|
|
783
|
+
*/
|
|
784
|
+
export async function act(fn: () => void | Promise<void>): Promise<void> {
|
|
785
|
+
const result = fn()
|
|
786
|
+
if (result instanceof Promise) await result
|
|
787
|
+
// Flush two rounds of microtasks to drain pending effects and rerenders
|
|
788
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
789
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ─── version ────────────────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
export const version = '19.0.0-pyreon'
|
|
795
|
+
|
|
796
|
+
// ─── StrictMode / Profiler ──────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* React-compatible `StrictMode` — pass-through in Pyreon (no double-invoke behavior).
|
|
800
|
+
*/
|
|
801
|
+
export function StrictMode(props: { children?: VNodeChild }): VNodeChild {
|
|
802
|
+
return props.children ?? null
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* React-compatible `Profiler` — pass-through in Pyreon (no profiling integration).
|
|
807
|
+
*/
|
|
808
|
+
export function Profiler(props: {
|
|
809
|
+
id: string
|
|
810
|
+
onRender?: (...args: unknown[]) => void
|
|
811
|
+
children?: VNodeChild
|
|
812
|
+
}): VNodeChild {
|
|
813
|
+
return props.children ?? null
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ─── Component / PureComponent (class stubs) ────────────────────────────────
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* React-compatible `Component` class stub.
|
|
820
|
+
* Class components are not fully supported — use function components with hooks.
|
|
821
|
+
*/
|
|
822
|
+
export class Component<P = Record<string, unknown>, S = Record<string, unknown>> {
|
|
823
|
+
props: Readonly<P>
|
|
824
|
+
state: Readonly<S>
|
|
825
|
+
|
|
826
|
+
constructor(props: P) {
|
|
827
|
+
this.props = props
|
|
828
|
+
this.state = {} as S
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
setState(_partial: Partial<S> | ((prev: S) => Partial<S>)): void {
|
|
832
|
+
console.warn(
|
|
833
|
+
'[Pyreon] Class component setState is not supported. Use function components with hooks.',
|
|
834
|
+
)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
forceUpdate(): void {
|
|
838
|
+
console.warn(
|
|
839
|
+
'[Pyreon] Class component forceUpdate is not supported. Use function components with hooks.',
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
render(): VNodeChild {
|
|
844
|
+
return null
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* React-compatible `PureComponent` class stub.
|
|
850
|
+
*/
|
|
851
|
+
export class PureComponent<
|
|
852
|
+
P = Record<string, unknown>,
|
|
853
|
+
S = Record<string, unknown>,
|
|
854
|
+
> extends Component<P, S> {}
|
|
855
|
+
|
|
856
|
+
// ─── React-compatible type exports ──────────────────────────────────────────
|
|
857
|
+
|
|
858
|
+
export type { Context }
|
|
859
|
+
|
|
860
|
+
export type FC<P = Record<string, unknown>> = (props: P) => VNodeChild
|
|
861
|
+
export type FunctionComponent<P = Record<string, unknown>> = FC<P>
|
|
862
|
+
export type ReactElement = VNode
|
|
863
|
+
export type ReactNode = VNodeChild
|
|
864
|
+
export type JSXElementConstructor<P> = (props: P) => VNodeChild
|
|
865
|
+
export type Dispatch<A> = (action: A) => void
|
|
866
|
+
export type SetStateAction<S> = S | ((prev: S) => S)
|
|
867
|
+
export type RefObject<T> = { readonly current: T | null }
|
|
868
|
+
export type MutableRefObject<T> = { current: T }
|
|
869
|
+
export type RefCallback<T> = (instance: T | null) => void
|
|
870
|
+
export type ForwardedRef<T> = RefObject<T> | RefCallback<T> | null
|
|
871
|
+
export type PropsWithChildren<P = Record<string, unknown>> = P & { children?: ReactNode }
|
|
872
|
+
export type PropsWithRef<P> = P & { ref?: RefObject<unknown> | RefCallback<unknown> | null }
|
|
873
|
+
export type { CSSProperties } from '@pyreon/core'
|
|
874
|
+
|
|
875
|
+
// Event types — aliases for TargetedEvent patterns
|
|
876
|
+
export type SyntheticEvent<T = Element> = Event & { currentTarget: T }
|
|
877
|
+
export type ChangeEvent<T = Element> = Event & { currentTarget: T; target: T }
|
|
878
|
+
export type FormEvent<T = Element> = Event & { currentTarget: T }
|
|
879
|
+
export type MouseEvent<T = Element> = globalThis.MouseEvent & { currentTarget: T }
|
|
880
|
+
export type KeyboardEvent<T = Element> = globalThis.KeyboardEvent & { currentTarget: T }
|
|
881
|
+
export type FocusEvent<T = Element> = globalThis.FocusEvent & { currentTarget: T }
|
|
882
|
+
export type DragEvent<T = Element> = globalThis.DragEvent & { currentTarget: T }
|
|
883
|
+
export type PointerEvent<T = Element> = globalThis.PointerEvent & { currentTarget: T }
|
|
884
|
+
export type TouchEvent<T = Element> = globalThis.TouchEvent & { currentTarget: T }
|
|
885
|
+
export type ClipboardEvent<T = Element> = globalThis.ClipboardEvent & { currentTarget: T }
|
|
886
|
+
export type AnimationEvent<T = Element> = globalThis.AnimationEvent & { currentTarget: T }
|
|
887
|
+
export type TransitionEvent<T = Element> = globalThis.TransitionEvent & { currentTarget: T }
|
|
888
|
+
export type WheelEvent<T = Element> = globalThis.WheelEvent & { currentTarget: T }
|
|
889
|
+
|
|
890
|
+
// HTML attribute types
|
|
891
|
+
export type HTMLAttributes<T = HTMLElement> = Record<string, unknown> & {
|
|
892
|
+
ref?: RefObject<T> | RefCallback<T> | null
|
|
893
|
+
}
|
|
894
|
+
export type InputHTMLAttributes<T = HTMLInputElement> = HTMLAttributes<T>
|
|
895
|
+
export type TextareaHTMLAttributes<T = HTMLTextAreaElement> = HTMLAttributes<T>
|
|
896
|
+
export type SelectHTMLAttributes<T = HTMLSelectElement> = HTMLAttributes<T>
|
|
897
|
+
export type ButtonHTMLAttributes<T = HTMLButtonElement> = HTMLAttributes<T>
|
|
898
|
+
export type AnchorHTMLAttributes<T = HTMLAnchorElement> = HTMLAttributes<T>
|
|
899
|
+
export type FormHTMLAttributes<T = HTMLFormElement> = HTMLAttributes<T>
|
|
900
|
+
export type ImgHTMLAttributes<T = HTMLImageElement> = HTMLAttributes<T>
|
|
901
|
+
export type SVGAttributes<T = SVGElement> = HTMLAttributes<T>
|