@pyreon/react-compat 0.13.1 → 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.
@@ -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[]))
@@ -25,6 +25,7 @@ function createHookRunner() {
25
25
  const ctx: RenderContext = {
26
26
  hooks: [],
27
27
  scheduleRerender: () => {},
28
+ pendingInsertionEffects: [],
28
29
  pendingEffects: [],
29
30
  pendingLayoutEffects: [],
30
31
  unmounted: false,