@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.
@@ -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, isNativeCompat, 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
+ process.env.NODE_ENV !== 'production' &&
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,17 @@ 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 (context Provider, RouterView, QueryClientProvider,
199
+ // etc.) skip compat wrapping — they manage their own reactivity via signals
200
+ // and Pyreon's lifecycle, and wrapping them would run their setup body inside
201
+ // the compat layer's render context instead of Pyreon's, breaking `provide()`,
202
+ // `onMount()`, and `onUnmount()`.
203
+ if (isNativeCompat(type)) {
204
+ return h(type as ComponentFn, componentProps)
205
+ }
147
206
  // Wrap React-style component for re-render support
148
207
  const wrapped = wrapCompatComponent(type)
149
- const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
150
208
  return h(wrapped, componentProps)
151
209
  }
152
210
 
@@ -163,6 +221,39 @@ export function jsx(
163
221
  propsWithKey.for = propsWithKey.htmlFor
164
222
  delete propsWithKey.htmlFor
165
223
  }
224
+
225
+ // React's onChange fires on every keystroke for form elements (like onInput)
226
+ if (
227
+ (type === 'input' || type === 'textarea' || type === 'select') &&
228
+ propsWithKey.onChange !== undefined
229
+ ) {
230
+ if (propsWithKey.onInput === undefined) {
231
+ propsWithKey.onInput = propsWithKey.onChange
232
+ }
233
+ delete propsWithKey.onChange
234
+ }
235
+
236
+ // autoFocus → autofocus
237
+ if (propsWithKey.autoFocus !== undefined) {
238
+ propsWithKey.autofocus = propsWithKey.autoFocus
239
+ delete propsWithKey.autoFocus
240
+ }
241
+
242
+ // defaultValue / defaultChecked → value / checked when no controlled value
243
+ if (type === 'input' || type === 'textarea') {
244
+ if (propsWithKey.defaultValue !== undefined && propsWithKey.value === undefined) {
245
+ propsWithKey.value = propsWithKey.defaultValue
246
+ delete propsWithKey.defaultValue
247
+ }
248
+ if (propsWithKey.defaultChecked !== undefined && propsWithKey.checked === undefined) {
249
+ propsWithKey.checked = propsWithKey.defaultChecked
250
+ delete propsWithKey.defaultChecked
251
+ }
252
+ }
253
+
254
+ // Strip React-only props that have no DOM equivalent
255
+ delete propsWithKey.suppressHydrationWarning
256
+ delete propsWithKey.suppressContentEditableWarning
166
257
  }
167
258
 
168
259
  return h(type, propsWithKey, ...(childArray as VNodeChild[]))
@@ -0,0 +1,59 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { jsx } from './jsx-runtime'
5
+ import { useState } from './index'
6
+
7
+ /**
8
+ * Real-browser regression test for the react-compat re-render path.
9
+ *
10
+ * The compat wrapper schedules re-renders via `scheduleRerender` →
11
+ * `queueMicrotask` → `version.set(...)`. Pyreon's `mountReactive`
12
+ * detects the version change and re-runs the accessor, which re-runs
13
+ * the user component and produces a new VNode tree. mountReactive
14
+ * tears down the old subtree and mounts the new one.
15
+ *
16
+ * **Important**: react-compat does FULL DOM REPLACEMENT on every
17
+ * re-render — there is no VDOM diffing in the compat layer (Pyreon's
18
+ * native pattern is fine-grained reactivity, not whole-component
19
+ * re-renders). Tests that capture a DOM reference BEFORE click and
20
+ * then assert on it AFTER click will see stale content because the
21
+ * captured reference points to a now-detached node. **Always re-query
22
+ * the DOM after a state change**.
23
+ *
24
+ * Phase A2's smoke for react-compat held a stale reference and
25
+ * appeared to show the wrapper was broken; this test characterises
26
+ * the correct behavior + the re-query gotcha so future authors don't
27
+ * trip on the same edge.
28
+ */
29
+ describe('@pyreon/react-compat — real-browser re-render', () => {
30
+ it('clicking a button increments useState count and DOM reflects', async () => {
31
+ function Counter(): VNodeChild {
32
+ const [count, setCount] = useState(0)
33
+ return jsx('button', {
34
+ id: 'rc-counter',
35
+ onClick: () => setCount((n: number) => n + 1),
36
+ children: `count: ${count}`,
37
+ })
38
+ }
39
+
40
+ const { container, unmount } = mountInBrowser(jsx(Counter, {}))
41
+ // Read 1: initial mount
42
+ expect(container.querySelector('#rc-counter')!.textContent).toBe('count: 0')
43
+
44
+ // Click the CURRENT button — re-query after each interaction because
45
+ // react-compat replaces the DOM subtree on re-render (see file-level
46
+ // doc comment).
47
+ container.querySelector<HTMLButtonElement>('#rc-counter')!.click()
48
+ await flush()
49
+ await flush()
50
+ expect(container.querySelector('#rc-counter')!.textContent).toBe('count: 1')
51
+
52
+ container.querySelector<HTMLButtonElement>('#rc-counter')!.click()
53
+ await flush()
54
+ await flush()
55
+ expect(container.querySelector('#rc-counter')!.textContent).toBe('count: 2')
56
+
57
+ unmount()
58
+ })
59
+ })
@@ -0,0 +1,34 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { jsx } from './jsx-runtime'
5
+ import { useState } from './index'
6
+
7
+ /**
8
+ * Real-browser smoke test for `@pyreon/react-compat`.
9
+ *
10
+ * Per the test-environment-parity rule (`pyreon/require-browser-smoke-test`),
11
+ * every browser-categorized package must ship at least one `*.browser.test.*`
12
+ * file. This catches regressions that happy-dom / hook-runner unit tests
13
+ * can hide: importing the public API, mounting through the JSX runtime
14
+ * wrapper, and exercising the React-style hook entry point in a real
15
+ * browser DOM (not a Node DOM polyfill).
16
+ *
17
+ * Companion unit tests in `src/tests/*.test.ts` test the hook semantics
18
+ * via `withHookCtx` runners. This smoke proves the integration: the
19
+ * package can be imported and mounted end-to-end in real Chromium.
20
+ */
21
+ describe('@pyreon/react-compat — browser smoke', () => {
22
+ it('renders a component using useState in real browser', () => {
23
+ function Greeting(): VNodeChild {
24
+ const [name] = useState('Pyreon')
25
+ return jsx('div', { id: 'greeting', children: `hello, ${name}` })
26
+ }
27
+
28
+ const { container, unmount } = mountInBrowser(jsx(Greeting, {}))
29
+ const greeting = container.querySelector('#greeting')!
30
+ expect(greeting.textContent).toBe('hello, Pyreon')
31
+ unmount()
32
+ expect(document.getElementById('greeting')).toBeNull()
33
+ })
34
+ })
@@ -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,
@@ -0,0 +1,88 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { createContext, h, nativeCompat, provide, useContext } from '@pyreon/core'
3
+ import { mount } from '@pyreon/runtime-dom'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { jsx } from '../jsx-runtime'
6
+
7
+ // Per-compat unit-level regression test for the marker-bypass contract.
8
+ //
9
+ // PR #422 wired `isNativeCompat(type)` into react-compat's `jsx()` runtime.
10
+ // PR #425 added `nativeCompat()` calls to 24 framework components. This file
11
+ // proves the bypass actually works at the unit-test layer:
12
+ //
13
+ // 1. **Bypass identity** (load-bearing): `jsx(NativeProvider, {})` returns
14
+ // vnode with `type === NativeProvider` (not the wrapper), proving the
15
+ // marker check fires.
16
+ // 2. **Wrap-when-unmarked** (load-bearing): UNMARKED components still go
17
+ // through `wrapCompatComponent` — proves the bypass is selective, not
18
+ // blanket.
19
+ // 3. **Mount + provide() smoke** (sanity, not bisect-load-bearing): the
20
+ // marked Provider mounts cleanly through compat-mode jsx() and its
21
+ // `provide()` reaches the descendant Consumer. Note: synchronous mount
22
+ // preserves provide() context even WITH the wrapper (provide() pushes
23
+ // onto the global context stack regardless), so removing the marker
24
+ // from a Provider in this test won't fail — the actual bug post-mark
25
+ // removal is multi-render-cycle (signal change re-fires the wrapper's
26
+ // accessor → provide() in re-run lands in stale stack). PR #427's e2e
27
+ // gate covers that shape end-to-end against real router state.
28
+ //
29
+ // Bisect-verified: removing the `if (isNativeCompat(type))` branch from
30
+ // jsx-runtime.ts causes test #1 to fail with
31
+ // `expected [Function wrapped] to be [Function Native]`.
32
+
33
+ function container(): HTMLElement {
34
+ const el = document.createElement('div')
35
+ document.body.appendChild(el)
36
+ return el
37
+ }
38
+
39
+ describe('react-compat — nativeCompat() marker bypass', () => {
40
+ it('jsx() routes marked components through h() directly (no wrapper)', () => {
41
+ const Native = (props: { children?: unknown }) => h('div', null, props.children as never)
42
+ nativeCompat(Native)
43
+
44
+ const vnode = jsx(Native, {})
45
+
46
+ // Bypass: vnode.type IS the source fn, not a cached wrapper.
47
+ expect(vnode.type).toBe(Native)
48
+ })
49
+
50
+ it('jsx() wraps UNMARKED components (control — bypass is selective)', () => {
51
+ const Unmarked = (props: { children?: unknown }) => h('div', null, props.children as never)
52
+ // No nativeCompat() call.
53
+
54
+ const vnode = jsx(Unmarked, {})
55
+
56
+ // Wrapper: vnode.type is the cached wrapper, NOT the source fn.
57
+ expect(vnode.type).not.toBe(Unmarked)
58
+ expect(typeof vnode.type).toBe('function')
59
+ })
60
+
61
+ it('marked Provider mounts inside Pyreon setup frame — provide() reaches descendants', () => {
62
+ const Ctx = createContext<string>('default')
63
+
64
+ const Provider: ComponentFn = (props) => {
65
+ provide(Ctx, props.value as string)
66
+ return props.children as never
67
+ }
68
+ nativeCompat(Provider)
69
+
70
+ const Consumer: ComponentFn = () => {
71
+ const value = useContext(Ctx)
72
+ return h('span', { 'data-value': value }, value)
73
+ }
74
+ nativeCompat(Consumer)
75
+
76
+ const el = container()
77
+ mount(jsx(Provider, { value: 'native', children: jsx(Consumer, {}) }), el)
78
+
79
+ const span = el.querySelector('span')
80
+ // Pre-PR-3, the Provider's body would run in the compat wrapper's
81
+ // runUntracked accessor and `provide()` would land in a torn-down
82
+ // context stack. Consumer would read 'default'. Post-PR-3, the marker
83
+ // routes Provider through h() directly, the body runs inside Pyreon's
84
+ // setup frame, provide() reaches descendants. Consumer reads 'native'.
85
+ expect(span?.getAttribute('data-value')).toBe('native')
86
+ expect(span?.textContent).toBe('native')
87
+ })
88
+ })