@pyreon/react-compat 0.2.1 → 0.3.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/index.js +159 -76
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +159 -74
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +34 -41
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/index.ts +196 -175
- package/src/jsx-runtime.ts +169 -0
- package/src/tests/react-compat.test.ts +457 -290
package/src/index.ts
CHANGED
|
@@ -1,274 +1,295 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pyreon/react-compat
|
|
3
3
|
*
|
|
4
|
-
* React-compatible hook API
|
|
4
|
+
* Fully React-compatible hook API powered by Pyreon's reactive engine.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* DIFFERENCES FROM REACT:
|
|
10
|
-
* - No hooks rules: call these anywhere in a component, in loops, conditions, etc.
|
|
11
|
-
* - useEffect deps array is IGNORED — Pyreon tracks dependencies automatically.
|
|
12
|
-
* - useCallback/memo are identity functions — no re-renders means no stale closures.
|
|
13
|
-
* - Components run ONCE (setup), not on every render.
|
|
6
|
+
* Components re-render on state change — just like React. Hooks return plain
|
|
7
|
+
* values and use deps arrays for memoization. Existing React code works
|
|
8
|
+
* unchanged when paired with `pyreon({ compat: "react" })` in your vite config.
|
|
14
9
|
*
|
|
15
10
|
* USAGE:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Replace `import { createRoot } from "react-dom/client"` with
|
|
19
|
-
* `import { createRoot } from "@pyreon/react-compat/dom"`
|
|
11
|
+
* import { useState, useEffect } from "react" // aliased by vite plugin
|
|
12
|
+
* import { createRoot } from "react-dom/client" // aliased by vite plugin
|
|
20
13
|
*/
|
|
21
14
|
|
|
22
15
|
export type { Props, VNode as ReactNode, VNodeChild } from "@pyreon/core"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// ─── State ────────────────────────────────────────────────────────────────────
|
|
16
|
+
export { Fragment, h as createElement, h } from "@pyreon/core"
|
|
17
|
+
|
|
18
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
19
|
+
import { createContext, ErrorBoundary, Portal, Suspense, useContext } from "@pyreon/core"
|
|
20
|
+
import { batch } from "@pyreon/reactivity"
|
|
21
|
+
import type { EffectEntry } from "./jsx-runtime"
|
|
22
|
+
import { getCurrentCtx, getHookIndex } from "./jsx-runtime"
|
|
23
|
+
|
|
24
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function requireCtx() {
|
|
27
|
+
const ctx = getCurrentCtx()
|
|
28
|
+
if (!ctx) throw new Error("Hook called outside of a component render")
|
|
29
|
+
return ctx
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function depsChanged(a: unknown[] | undefined, b: unknown[] | undefined): boolean {
|
|
33
|
+
if (a === undefined || b === undefined) return true
|
|
34
|
+
if (a.length !== b.length) return true
|
|
35
|
+
for (let i = 0; i < a.length; i++) {
|
|
36
|
+
if (!Object.is(a[i], b[i])) return true
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
51
42
|
|
|
52
43
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* Unlike React: the getter is a signal, so any component or effect that reads
|
|
57
|
-
* it will re-run automatically. No dep arrays needed.
|
|
44
|
+
* React-compatible `useState` — returns `[value, setter]`.
|
|
45
|
+
* Triggers a component re-render when the setter is called.
|
|
58
46
|
*/
|
|
59
|
-
export function useState<T>(initial: T | (() => T)): [
|
|
60
|
-
const
|
|
47
|
+
export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T)) => void] {
|
|
48
|
+
const ctx = requireCtx()
|
|
49
|
+
const idx = getHookIndex()
|
|
50
|
+
|
|
51
|
+
if (ctx.hooks.length <= idx) {
|
|
52
|
+
ctx.hooks.push(typeof initial === "function" ? (initial as () => T)() : initial)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const value = ctx.hooks[idx] as T
|
|
61
56
|
const setter = (v: T | ((prev: T) => T)) => {
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
const current = ctx.hooks[idx] as T
|
|
58
|
+
const next = typeof v === "function" ? (v as (prev: T) => T)(current) : v
|
|
59
|
+
if (Object.is(current, next)) return
|
|
60
|
+
ctx.hooks[idx] = next
|
|
61
|
+
ctx.scheduleRerender()
|
|
64
62
|
}
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
return [value, setter]
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
|
-
*
|
|
70
|
+
* React-compatible `useReducer` — returns `[state, dispatch]`.
|
|
72
71
|
*/
|
|
73
72
|
export function useReducer<S, A>(
|
|
74
73
|
reducer: (state: S, action: A) => S,
|
|
75
74
|
initial: S | (() => S),
|
|
76
|
-
): [
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
75
|
+
): [S, (action: A) => void] {
|
|
76
|
+
const ctx = requireCtx()
|
|
77
|
+
const idx = getHookIndex()
|
|
78
|
+
|
|
79
|
+
if (ctx.hooks.length <= idx) {
|
|
80
|
+
ctx.hooks.push(typeof initial === "function" ? (initial as () => S)() : initial)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const state = ctx.hooks[idx] as S
|
|
84
|
+
const dispatch = (action: A) => {
|
|
85
|
+
const current = ctx.hooks[idx] as S
|
|
86
|
+
const next = reducer(current, action)
|
|
87
|
+
if (Object.is(current, next)) return
|
|
88
|
+
ctx.hooks[idx] = next
|
|
89
|
+
ctx.scheduleRerender()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [state, dispatch]
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
// ─── Effects ─────────────────────────────────────────────────────────────────
|
|
83
96
|
|
|
84
97
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* The `deps` array is IGNORED — Pyreon tracks reactive dependencies automatically.
|
|
88
|
-
* If `deps` is `[]` (mount-only), wrap the body in `runUntracked(() => ...)`.
|
|
89
|
-
*
|
|
90
|
-
* Returns a cleanup the same way React does (return a function from `fn`).
|
|
98
|
+
* React-compatible `useEffect` — runs after render when deps change.
|
|
99
|
+
* Returns cleanup on unmount and before re-running.
|
|
91
100
|
*/
|
|
92
|
-
// biome-ignore lint/suspicious/noConfusingVoidType:
|
|
93
|
-
export function useEffect(fn: () =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
101
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: matches React's useEffect signature
|
|
102
|
+
export function useEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
|
|
103
|
+
const ctx = requireCtx()
|
|
104
|
+
const idx = getHookIndex()
|
|
105
|
+
|
|
106
|
+
if (ctx.hooks.length <= idx) {
|
|
107
|
+
// First render — always run
|
|
108
|
+
const entry: EffectEntry = { fn, deps, cleanup: undefined }
|
|
109
|
+
ctx.hooks.push(entry)
|
|
110
|
+
ctx.pendingEffects.push(entry)
|
|
100
111
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
112
|
+
const entry = ctx.hooks[idx] as EffectEntry
|
|
113
|
+
if (depsChanged(entry.deps, deps)) {
|
|
114
|
+
entry.fn = fn
|
|
115
|
+
entry.deps = deps
|
|
116
|
+
ctx.pendingEffects.push(entry)
|
|
117
|
+
}
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
/**
|
|
112
|
-
*
|
|
113
|
-
* In Pyreon there is no paint distinction — maps to `onMount` (same as useEffect).
|
|
122
|
+
* React-compatible `useLayoutEffect` — runs synchronously after DOM mutations.
|
|
114
123
|
*/
|
|
115
|
-
|
|
124
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: matches React's useLayoutEffect signature
|
|
125
|
+
export function useLayoutEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
|
|
126
|
+
const ctx = requireCtx()
|
|
127
|
+
const idx = getHookIndex()
|
|
128
|
+
|
|
129
|
+
if (ctx.hooks.length <= idx) {
|
|
130
|
+
const entry: EffectEntry = { fn, deps, cleanup: undefined }
|
|
131
|
+
ctx.hooks.push(entry)
|
|
132
|
+
ctx.pendingLayoutEffects.push(entry)
|
|
133
|
+
} else {
|
|
134
|
+
const entry = ctx.hooks[idx] as EffectEntry
|
|
135
|
+
if (depsChanged(entry.deps, deps)) {
|
|
136
|
+
entry.fn = fn
|
|
137
|
+
entry.deps = deps
|
|
138
|
+
ctx.pendingLayoutEffects.push(entry)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
116
142
|
|
|
117
143
|
// ─── Memoization ─────────────────────────────────────────────────────────────
|
|
118
144
|
|
|
119
145
|
/**
|
|
120
|
-
*
|
|
121
|
-
* The `deps` array is IGNORED — Pyreon's `computed` tracks dependencies automatically.
|
|
122
|
-
* Returns a getter: call `value()` to read the memoized result.
|
|
146
|
+
* React-compatible `useMemo` — returns the cached value, recomputed when deps change.
|
|
123
147
|
*/
|
|
124
|
-
export function useMemo<T>(fn: () => T,
|
|
125
|
-
|
|
148
|
+
export function useMemo<T>(fn: () => T, deps: unknown[]): T {
|
|
149
|
+
const ctx = requireCtx()
|
|
150
|
+
const idx = getHookIndex()
|
|
151
|
+
|
|
152
|
+
if (ctx.hooks.length <= idx) {
|
|
153
|
+
const value = fn()
|
|
154
|
+
ctx.hooks.push({ value, deps })
|
|
155
|
+
return value
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const entry = ctx.hooks[idx] as { value: T; deps: unknown[] }
|
|
159
|
+
if (depsChanged(entry.deps, deps)) {
|
|
160
|
+
entry.value = fn()
|
|
161
|
+
entry.deps = deps
|
|
162
|
+
}
|
|
163
|
+
return entry.value
|
|
126
164
|
}
|
|
127
165
|
|
|
128
166
|
/**
|
|
129
|
-
*
|
|
130
|
-
* In Pyreon, components run once so callbacks are never recreated — returns `fn` as-is.
|
|
167
|
+
* React-compatible `useCallback` — returns the cached function when deps haven't changed.
|
|
131
168
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return fn
|
|
169
|
+
export function useCallback<T extends (...args: never[]) => unknown>(fn: T, deps: unknown[]): T {
|
|
170
|
+
return useMemo(() => fn, deps)
|
|
135
171
|
}
|
|
136
172
|
|
|
137
|
-
// ─── Refs
|
|
173
|
+
// ─── Refs ────────────────────────────────────────────────────────────────────
|
|
138
174
|
|
|
139
175
|
/**
|
|
140
|
-
*
|
|
141
|
-
* Returns `{ current: T }` — same shape as React's ref object.
|
|
176
|
+
* React-compatible `useRef` — returns `{ current }` persisted across re-renders.
|
|
142
177
|
*/
|
|
143
178
|
export function useRef<T>(initial?: T): { current: T | null } {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
const ctx = requireCtx()
|
|
180
|
+
const idx = getHookIndex()
|
|
181
|
+
|
|
182
|
+
if (ctx.hooks.length <= idx) {
|
|
183
|
+
const ref = { current: initial !== undefined ? (initial as T) : null }
|
|
184
|
+
ctx.hooks.push(ref)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return ctx.hooks[idx] as { current: T | null }
|
|
147
188
|
}
|
|
148
189
|
|
|
149
190
|
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
150
191
|
|
|
151
|
-
/**
|
|
152
|
-
* Drop-in for React's `createContext` + `useContext`.
|
|
153
|
-
* Usage mirrors React: `const Ctx = createContext(defaultValue)`.
|
|
154
|
-
*/
|
|
155
192
|
export { createContext, useContext }
|
|
156
193
|
|
|
157
|
-
// ─── ID
|
|
194
|
+
// ─── ID ──────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
let _idCounter = 0
|
|
158
197
|
|
|
159
198
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* Uses the component's effectScope as the key so the counter starts at 0 for every
|
|
163
|
-
* component on both server and client — IDs are deterministic and hydration-safe.
|
|
199
|
+
* React-compatible `useId` — returns a stable unique string per hook call.
|
|
164
200
|
*/
|
|
165
|
-
const _idCounters = new WeakMap<object, number>()
|
|
166
|
-
|
|
167
201
|
export function useId(): string {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
202
|
+
const ctx = requireCtx()
|
|
203
|
+
const idx = getHookIndex()
|
|
204
|
+
|
|
205
|
+
if (ctx.hooks.length <= idx) {
|
|
206
|
+
ctx.hooks.push(`:r${(_idCounter++).toString(36)}:`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return ctx.hooks[idx] as string
|
|
173
210
|
}
|
|
174
211
|
|
|
175
|
-
// ─── Optimization
|
|
212
|
+
// ─── Optimization ────────────────────────────────────────────────────────────
|
|
176
213
|
|
|
177
214
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* Kept for API compatibility when migrating React code.
|
|
215
|
+
* React-compatible `memo` — wraps a component to skip re-render when props
|
|
216
|
+
* are shallowly equal.
|
|
181
217
|
*/
|
|
182
218
|
export function memo<P extends Record<string, unknown>>(
|
|
183
219
|
component: (props: P) => VNodeChild,
|
|
220
|
+
areEqual?: (prevProps: P, nextProps: P) => boolean,
|
|
184
221
|
): (props: P) => VNodeChild {
|
|
185
|
-
|
|
222
|
+
const compare =
|
|
223
|
+
areEqual ??
|
|
224
|
+
((a: P, b: P) => {
|
|
225
|
+
const keysA = Object.keys(a)
|
|
226
|
+
const keysB = Object.keys(b)
|
|
227
|
+
if (keysA.length !== keysB.length) return false
|
|
228
|
+
for (const k of keysA) {
|
|
229
|
+
if (!Object.is(a[k], b[k])) return false
|
|
230
|
+
}
|
|
231
|
+
return true
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
let prevProps: P | null = null
|
|
235
|
+
let prevResult: VNodeChild = null
|
|
236
|
+
|
|
237
|
+
return (props: P) => {
|
|
238
|
+
if (prevProps !== null && compare(prevProps, props)) {
|
|
239
|
+
return prevResult
|
|
240
|
+
}
|
|
241
|
+
prevProps = props
|
|
242
|
+
prevResult = (component as (p: P) => VNodeChild)(props)
|
|
243
|
+
return prevResult
|
|
244
|
+
}
|
|
186
245
|
}
|
|
187
246
|
|
|
188
247
|
/**
|
|
189
|
-
*
|
|
190
|
-
* Returns `[false, (fn) => fn()]` to keep code runnable without changes.
|
|
248
|
+
* React-compatible `useTransition` — no concurrent mode in Pyreon.
|
|
191
249
|
*/
|
|
192
250
|
export function useTransition(): [boolean, (fn: () => void) => void] {
|
|
193
251
|
return [false, (fn) => fn()]
|
|
194
252
|
}
|
|
195
253
|
|
|
196
254
|
/**
|
|
197
|
-
*
|
|
255
|
+
* React-compatible `useDeferredValue` — returns the value as-is.
|
|
198
256
|
*/
|
|
199
257
|
export function useDeferredValue<T>(value: T): T {
|
|
200
258
|
return value
|
|
201
259
|
}
|
|
202
260
|
|
|
203
|
-
// ───
|
|
261
|
+
// ─── Imperative handle ───────────────────────────────────────────────────────
|
|
204
262
|
|
|
205
263
|
/**
|
|
206
|
-
*
|
|
207
|
-
* Pyreon's `batch()` does the same thing.
|
|
208
|
-
*/
|
|
209
|
-
export { batch }
|
|
210
|
-
|
|
211
|
-
// ─── Error boundaries ─────────────────────────────────────────────────────────
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Drop-in for React's error boundary pattern.
|
|
215
|
-
* Return `true` from `handler` to prevent error propagation (like `componentDidCatch`).
|
|
216
|
-
*/
|
|
217
|
-
export { onErrorCaptured as useErrorBoundary }
|
|
218
|
-
|
|
219
|
-
// ─── Portals ─────────────────────────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Drop-in for React's `createPortal(children, target)`.
|
|
223
|
-
*/
|
|
224
|
-
export function createPortal(children: VNodeChild, target: Element): VNodeChild {
|
|
225
|
-
return Portal({ target, children })
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ─── Imperative handle ────────────────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Drop-in for React's `useImperativeHandle`.
|
|
232
|
-
* In Pyreon, expose methods via a ref prop directly — this is a compatibility shim.
|
|
264
|
+
* React-compatible `useImperativeHandle`.
|
|
233
265
|
*/
|
|
234
266
|
export function useImperativeHandle<T>(
|
|
235
267
|
ref: { current: T | null } | null | undefined,
|
|
236
268
|
init: () => T,
|
|
237
|
-
|
|
269
|
+
deps?: unknown[],
|
|
238
270
|
): void {
|
|
239
|
-
|
|
271
|
+
useLayoutEffect(() => {
|
|
240
272
|
if (ref) ref.current = init()
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
})
|
|
273
|
+
return () => {
|
|
274
|
+
if (ref) ref.current = null
|
|
275
|
+
}
|
|
276
|
+
}, deps)
|
|
245
277
|
}
|
|
246
278
|
|
|
247
|
-
// ───
|
|
279
|
+
// ─── Batching ────────────────────────────────────────────────────────────────
|
|
248
280
|
|
|
249
|
-
|
|
250
|
-
* Pyreon-specific: O(1) equality selector (no React equivalent).
|
|
251
|
-
* Useful for large lists where only the selected item should re-render.
|
|
252
|
-
* @see createSelector in @pyreon/reactivity
|
|
253
|
-
*/
|
|
254
|
-
export { createSelector }
|
|
255
|
-
|
|
256
|
-
// ─── onUpdate ─────────────────────────────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
/** Pyreon-specific lifecycle hook — runs after each reactive update. */
|
|
259
|
-
export { onMount, onUnmount, onUpdate }
|
|
281
|
+
export { batch }
|
|
260
282
|
|
|
261
|
-
// ───
|
|
283
|
+
// ─── Portals ─────────────────────────────────────────────────────────────────
|
|
262
284
|
|
|
263
285
|
/**
|
|
264
|
-
*
|
|
265
|
-
* Re-exported from `@pyreon/core` — wraps a dynamic import, renders null until
|
|
266
|
-
* the module resolves. Pair with `<Suspense>` to show a fallback during loading.
|
|
286
|
+
* React-compatible `createPortal(children, target)`.
|
|
267
287
|
*/
|
|
268
|
-
export
|
|
288
|
+
export function createPortal(children: VNodeChild, target: Element): VNodeChild {
|
|
289
|
+
return Portal({ target, children })
|
|
290
|
+
}
|
|
269
291
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*/
|
|
292
|
+
// ─── Suspense / lazy / ErrorBoundary ─────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export { lazy } from "@pyreon/core"
|
|
274
295
|
export { ErrorBoundary, Suspense }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compat JSX runtime for React compatibility mode.
|
|
3
|
+
*
|
|
4
|
+
* When `jsxImportSource` is set to `@pyreon/react-compat` (via the vite plugin's
|
|
5
|
+
* `compat: "react"` option), OXC rewrites JSX to import from this file:
|
|
6
|
+
* <div className="x" /> → jsx("div", { className: "x" })
|
|
7
|
+
*
|
|
8
|
+
* For component VNodes, we wrap the component function so it returns a reactive
|
|
9
|
+
* accessor — enabling React-style re-renders on state change while Pyreon's
|
|
10
|
+
* existing renderer handles all DOM work.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
|
|
14
|
+
import { Fragment, h } from "@pyreon/core"
|
|
15
|
+
import { signal } from "@pyreon/reactivity"
|
|
16
|
+
|
|
17
|
+
export { Fragment }
|
|
18
|
+
|
|
19
|
+
// ─── Render context (used by hooks) ──────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface RenderContext {
|
|
22
|
+
hooks: unknown[]
|
|
23
|
+
scheduleRerender: () => void
|
|
24
|
+
/** Effect entries pending execution after render */
|
|
25
|
+
pendingEffects: EffectEntry[]
|
|
26
|
+
/** Layout effect entries pending execution after render */
|
|
27
|
+
pendingLayoutEffects: EffectEntry[]
|
|
28
|
+
/** Set to true when the component is unmounted */
|
|
29
|
+
unmounted: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EffectEntry {
|
|
33
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: matches React's effect signature
|
|
34
|
+
fn: () => (() => void) | void
|
|
35
|
+
deps: unknown[] | undefined
|
|
36
|
+
cleanup: (() => void) | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _currentCtx: RenderContext | null = null
|
|
40
|
+
let _hookIndex = 0
|
|
41
|
+
|
|
42
|
+
export function getCurrentCtx(): RenderContext | null {
|
|
43
|
+
return _currentCtx
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getHookIndex(): number {
|
|
47
|
+
return _hookIndex++
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function beginRender(ctx: RenderContext): void {
|
|
51
|
+
_currentCtx = ctx
|
|
52
|
+
_hookIndex = 0
|
|
53
|
+
ctx.pendingEffects = []
|
|
54
|
+
ctx.pendingLayoutEffects = []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function endRender(): void {
|
|
58
|
+
_currentCtx = null
|
|
59
|
+
_hookIndex = 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Effect runners ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function runLayoutEffects(entries: EffectEntry[]): void {
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.cleanup) entry.cleanup()
|
|
67
|
+
const cleanup = entry.fn()
|
|
68
|
+
entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
|
|
73
|
+
if (entries.length === 0) return
|
|
74
|
+
queueMicrotask(() => {
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (ctx.unmounted) return
|
|
77
|
+
if (entry.cleanup) entry.cleanup()
|
|
78
|
+
const cleanup = entry.fn()
|
|
79
|
+
entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Component wrapping ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
// biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
|
|
87
|
+
const _wrapperCache = new WeakMap<Function, ComponentFn>()
|
|
88
|
+
|
|
89
|
+
// biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
|
|
90
|
+
function wrapCompatComponent(reactComponent: Function): ComponentFn {
|
|
91
|
+
let wrapped = _wrapperCache.get(reactComponent)
|
|
92
|
+
if (wrapped) return wrapped
|
|
93
|
+
|
|
94
|
+
// The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
|
|
95
|
+
// mountChild treats as a reactive expression via mountReactive.
|
|
96
|
+
wrapped = ((props: Props) => {
|
|
97
|
+
const ctx: RenderContext = {
|
|
98
|
+
hooks: [],
|
|
99
|
+
scheduleRerender: () => {
|
|
100
|
+
// Will be replaced below after version signal is created
|
|
101
|
+
},
|
|
102
|
+
pendingEffects: [],
|
|
103
|
+
pendingLayoutEffects: [],
|
|
104
|
+
unmounted: false,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const version = signal(0)
|
|
108
|
+
let updateScheduled = false
|
|
109
|
+
|
|
110
|
+
ctx.scheduleRerender = () => {
|
|
111
|
+
if (ctx.unmounted || updateScheduled) return
|
|
112
|
+
updateScheduled = true
|
|
113
|
+
queueMicrotask(() => {
|
|
114
|
+
updateScheduled = false
|
|
115
|
+
if (!ctx.unmounted) version.set(version.peek() + 1)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Return reactive accessor — Pyreon's mountChild calls mountReactive
|
|
120
|
+
return () => {
|
|
121
|
+
version() // tracked read — triggers re-execution when state changes
|
|
122
|
+
beginRender(ctx)
|
|
123
|
+
const result = (reactComponent as ComponentFn)(props)
|
|
124
|
+
const layoutEffects = ctx.pendingLayoutEffects
|
|
125
|
+
const effects = ctx.pendingEffects
|
|
126
|
+
endRender()
|
|
127
|
+
|
|
128
|
+
runLayoutEffects(layoutEffects)
|
|
129
|
+
scheduleEffects(ctx, effects)
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
}) as unknown as ComponentFn
|
|
134
|
+
|
|
135
|
+
_wrapperCache.set(reactComponent, wrapped)
|
|
136
|
+
return wrapped
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── JSX functions ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export function jsx(
|
|
142
|
+
type: string | ComponentFn | symbol,
|
|
143
|
+
props: Props & { children?: VNodeChild | VNodeChild[] },
|
|
144
|
+
key?: string | number | null,
|
|
145
|
+
): VNode {
|
|
146
|
+
const { children, ...rest } = props
|
|
147
|
+
const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
|
|
148
|
+
|
|
149
|
+
if (typeof type === "function") {
|
|
150
|
+
// Wrap React-style component for re-render support
|
|
151
|
+
const wrapped = wrapCompatComponent(type)
|
|
152
|
+
const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
|
|
153
|
+
return h(wrapped, componentProps)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// DOM element or symbol (Fragment): children go in vnode.children
|
|
157
|
+
const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
|
|
158
|
+
|
|
159
|
+
// Map className → class for React compat
|
|
160
|
+
if (typeof type === "string" && propsWithKey.className !== undefined) {
|
|
161
|
+
propsWithKey.class = propsWithKey.className
|
|
162
|
+
delete propsWithKey.className
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return h(type, propsWithKey, ...(childArray as VNodeChild[]))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const jsxs = jsx
|
|
169
|
+
export const jsxDEV = jsx
|