@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/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, 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
|
+
})
|
|
@@ -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
|
+
})
|