@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/jsx-runtime.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
|
|
14
|
-
import { Fragment, h } from '@pyreon/core'
|
|
14
|
+
import { Fragment, h, onUnmount } from '@pyreon/core'
|
|
15
15
|
import { signal } from '@pyreon/reactivity'
|
|
16
16
|
|
|
17
17
|
export { Fragment }
|
|
@@ -21,12 +21,16 @@ export { Fragment }
|
|
|
21
21
|
export interface RenderContext {
|
|
22
22
|
hooks: unknown[]
|
|
23
23
|
scheduleRerender: () => void
|
|
24
|
+
/** Insertion effect entries pending execution before layout effects */
|
|
25
|
+
pendingInsertionEffects: EffectEntry[]
|
|
24
26
|
/** Effect entries pending execution after render */
|
|
25
27
|
pendingEffects: EffectEntry[]
|
|
26
28
|
/** Layout effect entries pending execution after render */
|
|
27
29
|
pendingLayoutEffects: EffectEntry[]
|
|
28
30
|
/** Set to true when the component is unmounted */
|
|
29
31
|
unmounted: boolean
|
|
32
|
+
/** Hook count from the previous render (dev-mode ordering guard) */
|
|
33
|
+
_hookCount?: number
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export interface EffectEntry {
|
|
@@ -37,6 +41,7 @@ export interface EffectEntry {
|
|
|
37
41
|
|
|
38
42
|
let _currentCtx: RenderContext | null = null
|
|
39
43
|
let _hookIndex = 0
|
|
44
|
+
let _expectedHookCount = -1
|
|
40
45
|
|
|
41
46
|
export function getCurrentCtx(): RenderContext | null {
|
|
42
47
|
return _currentCtx
|
|
@@ -49,11 +54,33 @@ export function getHookIndex(): number {
|
|
|
49
54
|
export function beginRender(ctx: RenderContext): void {
|
|
50
55
|
_currentCtx = ctx
|
|
51
56
|
_hookIndex = 0
|
|
57
|
+
ctx.pendingInsertionEffects = []
|
|
52
58
|
ctx.pendingEffects = []
|
|
53
59
|
ctx.pendingLayoutEffects = []
|
|
60
|
+
|
|
61
|
+
// On re-renders, remember the hook count from last render
|
|
62
|
+
if (ctx._hookCount !== undefined) {
|
|
63
|
+
_expectedHookCount = ctx._hookCount
|
|
64
|
+
} else {
|
|
65
|
+
_expectedHookCount = -1
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export function endRender(): void {
|
|
70
|
+
if (_currentCtx) {
|
|
71
|
+
// Dev-mode: check hook count matches expected
|
|
72
|
+
if (
|
|
73
|
+
(import.meta as { env?: { DEV?: boolean } }).env?.DEV &&
|
|
74
|
+
_expectedHookCount !== -1 &&
|
|
75
|
+
_hookIndex !== _expectedHookCount
|
|
76
|
+
) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[Pyreon] Hook count changed between renders (expected ${_expectedHookCount}, got ${_hookIndex}). ` +
|
|
79
|
+
`This usually means a hook is called conditionally. Hooks must be called in the same order every render.`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
_currentCtx._hookCount = _hookIndex
|
|
83
|
+
}
|
|
57
84
|
_currentCtx = null
|
|
58
85
|
_hookIndex = 0
|
|
59
86
|
}
|
|
@@ -96,6 +123,7 @@ function wrapCompatComponent(reactComponent: Function): ComponentFn {
|
|
|
96
123
|
scheduleRerender: () => {
|
|
97
124
|
// Will be replaced below after version signal is created
|
|
98
125
|
},
|
|
126
|
+
pendingInsertionEffects: [],
|
|
99
127
|
pendingEffects: [],
|
|
100
128
|
pendingLayoutEffects: [],
|
|
101
129
|
unmounted: false,
|
|
@@ -113,15 +141,37 @@ function wrapCompatComponent(reactComponent: Function): ComponentFn {
|
|
|
113
141
|
})
|
|
114
142
|
}
|
|
115
143
|
|
|
144
|
+
// Register cleanup for all hooks on unmount
|
|
145
|
+
onUnmount(() => {
|
|
146
|
+
ctx.unmounted = true
|
|
147
|
+
for (const hook of ctx.hooks) {
|
|
148
|
+
if (hook && typeof hook === 'object' && 'cleanup' in hook) {
|
|
149
|
+
const entry = hook as EffectEntry
|
|
150
|
+
if (typeof entry.cleanup === 'function') entry.cleanup()
|
|
151
|
+
}
|
|
152
|
+
if (hook && typeof hook === 'object' && 'unsubscribe' in hook) {
|
|
153
|
+
const sub = hook as { unsubscribe?: () => void }
|
|
154
|
+
if (typeof sub.unsubscribe === 'function') sub.unsubscribe()
|
|
155
|
+
}
|
|
156
|
+
if (hook && typeof hook === 'object' && '_contextUnsub' in hook) {
|
|
157
|
+
const ctxHook = hook as { _contextUnsub?: () => void }
|
|
158
|
+
if (typeof ctxHook._contextUnsub === 'function') ctxHook._contextUnsub()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
116
163
|
// Return reactive accessor — Pyreon's mountChild calls mountReactive
|
|
117
164
|
return () => {
|
|
118
165
|
version() // tracked read — triggers re-execution when state changes
|
|
119
166
|
beginRender(ctx)
|
|
120
167
|
const result = (reactComponent as ComponentFn)(props)
|
|
168
|
+
const insertionEffects = ctx.pendingInsertionEffects
|
|
121
169
|
const layoutEffects = ctx.pendingLayoutEffects
|
|
122
170
|
const effects = ctx.pendingEffects
|
|
123
171
|
endRender()
|
|
124
172
|
|
|
173
|
+
// Run in React's order: insertion → layout → passive
|
|
174
|
+
runLayoutEffects(insertionEffects)
|
|
125
175
|
runLayoutEffects(layoutEffects)
|
|
126
176
|
scheduleEffects(ctx, effects)
|
|
127
177
|
|
|
@@ -144,9 +194,14 @@ export function jsx(
|
|
|
144
194
|
const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
|
|
145
195
|
|
|
146
196
|
if (typeof type === 'function') {
|
|
197
|
+
const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
|
|
198
|
+
// Native Pyreon components (e.g. context Provider) skip compat wrapping
|
|
199
|
+
const NATIVE = Symbol.for('pyreon:native-compat')
|
|
200
|
+
if ((type as unknown as Record<symbol, boolean>)[NATIVE]) {
|
|
201
|
+
return h(type as ComponentFn, componentProps)
|
|
202
|
+
}
|
|
147
203
|
// Wrap React-style component for re-render support
|
|
148
204
|
const wrapped = wrapCompatComponent(type)
|
|
149
|
-
const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
|
|
150
205
|
return h(wrapped, componentProps)
|
|
151
206
|
}
|
|
152
207
|
|
|
@@ -163,6 +218,39 @@ export function jsx(
|
|
|
163
218
|
propsWithKey.for = propsWithKey.htmlFor
|
|
164
219
|
delete propsWithKey.htmlFor
|
|
165
220
|
}
|
|
221
|
+
|
|
222
|
+
// React's onChange fires on every keystroke for form elements (like onInput)
|
|
223
|
+
if (
|
|
224
|
+
(type === 'input' || type === 'textarea' || type === 'select') &&
|
|
225
|
+
propsWithKey.onChange !== undefined
|
|
226
|
+
) {
|
|
227
|
+
if (propsWithKey.onInput === undefined) {
|
|
228
|
+
propsWithKey.onInput = propsWithKey.onChange
|
|
229
|
+
}
|
|
230
|
+
delete propsWithKey.onChange
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// autoFocus → autofocus
|
|
234
|
+
if (propsWithKey.autoFocus !== undefined) {
|
|
235
|
+
propsWithKey.autofocus = propsWithKey.autoFocus
|
|
236
|
+
delete propsWithKey.autoFocus
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// defaultValue / defaultChecked → value / checked when no controlled value
|
|
240
|
+
if (type === 'input' || type === 'textarea') {
|
|
241
|
+
if (propsWithKey.defaultValue !== undefined && propsWithKey.value === undefined) {
|
|
242
|
+
propsWithKey.value = propsWithKey.defaultValue
|
|
243
|
+
delete propsWithKey.defaultValue
|
|
244
|
+
}
|
|
245
|
+
if (propsWithKey.defaultChecked !== undefined && propsWithKey.checked === undefined) {
|
|
246
|
+
propsWithKey.checked = propsWithKey.defaultChecked
|
|
247
|
+
delete propsWithKey.defaultChecked
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Strip React-only props that have no DOM equivalent
|
|
252
|
+
delete propsWithKey.suppressHydrationWarning
|
|
253
|
+
delete propsWithKey.suppressContentEditableWarning
|
|
166
254
|
}
|
|
167
255
|
|
|
168
256
|
return h(type, propsWithKey, ...(childArray as VNodeChild[]))
|