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