@pyreon/preact-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/hooks.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/jsx-runtime.js.html +1 -1
- package/lib/analysis/signals.js.html +1 -1
- package/lib/hooks.js +125 -36
- package/lib/index.js +23 -5
- package/lib/jsx-runtime.js +97 -6
- package/lib/signals.js +2 -2
- package/lib/types/hooks.d.ts +49 -2
- package/lib/types/index.d.ts +20 -3
- package/package.json +8 -4
- package/src/hooks.ts +157 -42
- package/src/index.ts +48 -4
- package/src/jsx-runtime.ts +145 -2
- package/src/preact-compat.browser.test.ts +37 -0
- package/src/signals.ts +2 -2
- package/src/tests/native-marker-bypass.test.tsx +63 -0
- package/src/tests/new-apis.test.ts +1084 -0
- package/lib/hooks.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/jsx-runtime.js.map +0 -1
- package/lib/signals.js.map +0 -1
- package/lib/types/hooks.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/lib/types/signals.d.ts.map +0 -1
package/src/hooks.ts
CHANGED
|
@@ -32,30 +32,45 @@ function depsChanged(a: unknown[] | undefined, b: unknown[] | undefined): boolea
|
|
|
32
32
|
return false
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function shallowEqual<P extends Record<string, unknown>>(a: P, b: P): boolean {
|
|
36
|
+
const keysA = Object.keys(a)
|
|
37
|
+
const keysB = Object.keys(b)
|
|
38
|
+
if (keysA.length !== keysB.length) return false
|
|
39
|
+
for (const k of keysA) {
|
|
40
|
+
if (!Object.is(a[k], b[k])) return false
|
|
41
|
+
}
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
// ─── useState ────────────────────────────────────────────────────────────────
|
|
36
46
|
|
|
37
47
|
/**
|
|
38
48
|
* Preact-compatible `useState` — returns `[value, setter]`.
|
|
39
49
|
* Triggers a component re-render when the setter is called.
|
|
50
|
+
*
|
|
51
|
+
* The setter has stable identity across renders (same reference every time).
|
|
40
52
|
*/
|
|
41
53
|
export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T)) => void] {
|
|
42
54
|
const ctx = requireCtx()
|
|
43
55
|
const idx = getHookIndex()
|
|
44
56
|
|
|
45
57
|
if (ctx.hooks.length <= idx) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const val = typeof initial === 'function' ? (initial as () => T)() : initial
|
|
59
|
+
// Store both value and a STABLE setter in one hook slot so setter identity
|
|
60
|
+
// never changes across renders (Preact/React guarantee).
|
|
61
|
+
const entry = { value: val, setter: null as unknown as (v: T | ((prev: T) => T)) => void }
|
|
62
|
+
entry.setter = (v: T | ((prev: T) => T)) => {
|
|
63
|
+
const current = entry.value
|
|
64
|
+
const next = typeof v === 'function' ? (v as (prev: T) => T)(current) : v
|
|
65
|
+
if (Object.is(current, next)) return
|
|
66
|
+
entry.value = next
|
|
67
|
+
ctx.scheduleRerender()
|
|
68
|
+
}
|
|
69
|
+
ctx.hooks.push(entry)
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
const entry = ctx.hooks[idx] as { value: T; setter: (v: T | ((prev: T) => T)) => void }
|
|
73
|
+
return [entry.value, entry.setter]
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
// ─── useEffect ───────────────────────────────────────────────────────────────
|
|
@@ -159,28 +174,42 @@ export function useRef<T>(initial?: T): { current: T | null } {
|
|
|
159
174
|
|
|
160
175
|
/**
|
|
161
176
|
* Preact-compatible `useReducer` — returns `[state, dispatch]`.
|
|
177
|
+
* Supports the 3-argument form: `useReducer(reducer, initialArg, init)`.
|
|
178
|
+
*
|
|
179
|
+
* Dispatch has stable identity across renders (same reference every time).
|
|
162
180
|
*/
|
|
163
181
|
export function useReducer<S, A>(
|
|
164
182
|
reducer: (state: S, action: A) => S,
|
|
165
|
-
|
|
183
|
+
initialArg: S | (() => S),
|
|
184
|
+
init?: (arg: S) => S,
|
|
166
185
|
): [S, (action: A) => void] {
|
|
167
186
|
const ctx = requireCtx()
|
|
168
187
|
const idx = getHookIndex()
|
|
169
188
|
|
|
170
189
|
if (ctx.hooks.length <= idx) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
let initial: S
|
|
191
|
+
if (init) {
|
|
192
|
+
initial = init(initialArg as S)
|
|
193
|
+
} else if (typeof initialArg === 'function') {
|
|
194
|
+
initial = (initialArg as () => S)()
|
|
195
|
+
} else {
|
|
196
|
+
initial = initialArg
|
|
197
|
+
}
|
|
198
|
+
// Store both value and a STABLE dispatch in one hook slot so dispatch identity
|
|
199
|
+
// never changes across renders (Preact/React guarantee).
|
|
200
|
+
const entry = { value: initial, dispatch: null as unknown as (action: A) => void }
|
|
201
|
+
entry.dispatch = (action: A) => {
|
|
202
|
+
const current = entry.value
|
|
203
|
+
const next = reducer(current, action)
|
|
204
|
+
if (Object.is(current, next)) return
|
|
205
|
+
entry.value = next
|
|
206
|
+
ctx.scheduleRerender()
|
|
207
|
+
}
|
|
208
|
+
ctx.hooks.push(entry)
|
|
181
209
|
}
|
|
182
210
|
|
|
183
|
-
|
|
211
|
+
const entry = ctx.hooks[idx] as { value: S; dispatch: (action: A) => void }
|
|
212
|
+
return [entry.value, entry.dispatch]
|
|
184
213
|
}
|
|
185
214
|
|
|
186
215
|
// ─── useId ───────────────────────────────────────────────────────────────────
|
|
@@ -206,34 +235,120 @@ export function useId(): string {
|
|
|
206
235
|
/**
|
|
207
236
|
* Preact-compatible `memo` — wraps a component to skip re-render when props
|
|
208
237
|
* are shallowly equal.
|
|
238
|
+
*
|
|
239
|
+
* Each component INSTANCE gets its own props/result cache via a hook slot,
|
|
240
|
+
* so two `<MemoComp />` usages don't share memoization state.
|
|
209
241
|
*/
|
|
210
242
|
export function memo<P extends Record<string, unknown>>(
|
|
211
243
|
component: (props: P) => VNodeChild,
|
|
212
244
|
areEqual?: (prevProps: P, nextProps: P) => boolean,
|
|
213
245
|
): (props: P) => VNodeChild {
|
|
214
|
-
const compare =
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
246
|
+
const compare = areEqual ?? shallowEqual
|
|
247
|
+
|
|
248
|
+
// Fallback closure-level cache for calls outside a compat render context
|
|
249
|
+
// (e.g. direct function calls in tests). Inside a render context, each
|
|
250
|
+
// component instance gets its own cache via a hook slot.
|
|
251
|
+
let _fallbackPrevProps: P | null = null
|
|
252
|
+
let _fallbackPrevResult: VNodeChild = null
|
|
253
|
+
|
|
254
|
+
const memoized = (props: P) => {
|
|
255
|
+
const ctx = getCurrentCtx()
|
|
256
|
+
if (ctx) {
|
|
257
|
+
// Per-instance cache via hook slot
|
|
258
|
+
const idx = getHookIndex()
|
|
259
|
+
if (ctx.hooks.length <= idx) {
|
|
260
|
+
ctx.hooks.push({ prevProps: null as P | null, prevResult: null as VNodeChild })
|
|
222
261
|
}
|
|
223
|
-
|
|
224
|
-
|
|
262
|
+
const cache = ctx.hooks[idx] as { prevProps: P | null; prevResult: VNodeChild }
|
|
263
|
+
if (cache.prevProps !== null && compare(cache.prevProps, props)) {
|
|
264
|
+
return cache.prevResult
|
|
265
|
+
}
|
|
266
|
+
cache.prevProps = props
|
|
267
|
+
cache.prevResult = component(props)
|
|
268
|
+
return cache.prevResult
|
|
269
|
+
}
|
|
270
|
+
// No compat context — use closure-level fallback cache
|
|
271
|
+
if (_fallbackPrevProps !== null && compare(_fallbackPrevProps, props)) {
|
|
272
|
+
return _fallbackPrevResult
|
|
273
|
+
}
|
|
274
|
+
_fallbackPrevProps = props
|
|
275
|
+
_fallbackPrevResult = component(props)
|
|
276
|
+
return _fallbackPrevResult
|
|
277
|
+
}
|
|
278
|
+
memoized.displayName =
|
|
279
|
+
(component as unknown as { displayName?: string }).displayName || component.name || 'Memo'
|
|
280
|
+
return memoized
|
|
281
|
+
}
|
|
225
282
|
|
|
226
|
-
|
|
227
|
-
let prevResult: VNodeChild = null
|
|
283
|
+
// ─── forwardRef ─────────────────────────────────────────────────────────────
|
|
228
284
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Preact-compatible `forwardRef` — pass-through in Pyreon.
|
|
287
|
+
* Refs are regular props in Pyreon, so no wrapper is needed.
|
|
288
|
+
* The render function receives (props, ref) — we merge ref into props.
|
|
289
|
+
*/
|
|
290
|
+
export function forwardRef<P extends Record<string, unknown>>(
|
|
291
|
+
render: (props: P, ref: { current: unknown } | null) => VNodeChild,
|
|
292
|
+
): (props: P & { ref?: { current: unknown } | null }) => VNodeChild {
|
|
293
|
+
const forwarded = (props: P & { ref?: { current: unknown } | null }) => {
|
|
294
|
+
const { ref, ...rest } = props
|
|
295
|
+
return render(rest as P, ref ?? null)
|
|
236
296
|
}
|
|
297
|
+
forwarded.displayName =
|
|
298
|
+
(render as unknown as { displayName?: string }).displayName || render.name || 'ForwardRef'
|
|
299
|
+
return forwarded
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── useImperativeHandle ────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Preact-compatible `useImperativeHandle`.
|
|
306
|
+
*/
|
|
307
|
+
export function useImperativeHandle<T>(
|
|
308
|
+
ref: { current: T | null } | null | undefined,
|
|
309
|
+
init: () => T,
|
|
310
|
+
deps?: unknown[],
|
|
311
|
+
): void {
|
|
312
|
+
useLayoutEffect(() => {
|
|
313
|
+
if (ref) ref.current = init()
|
|
314
|
+
return () => {
|
|
315
|
+
if (ref) ref.current = null
|
|
316
|
+
}
|
|
317
|
+
}, deps)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── useDebugValue ──────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Preact-compatible `useDebugValue` — no-op in Pyreon (no Preact DevTools integration).
|
|
324
|
+
*/
|
|
325
|
+
export function useDebugValue<T>(_value: T, _format?: (v: T) => unknown): void {}
|
|
326
|
+
|
|
327
|
+
// ─── useTransition ──────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Preact-compatible `useTransition` — returns `[isPending, startTransition]`.
|
|
331
|
+
*
|
|
332
|
+
* In Pyreon's signal-based reactivity there is no concept of concurrent
|
|
333
|
+
* rendering lanes. The callback is executed synchronously and `isPending`
|
|
334
|
+
* is always `false`. This shim exists so Preact/React code that uses
|
|
335
|
+
* `useTransition` compiles and runs without changes.
|
|
336
|
+
*/
|
|
337
|
+
export function useTransition(): [boolean, (fn: () => void) => void] {
|
|
338
|
+
return [false, (fn) => fn()]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── useDeferredValue ───────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Preact-compatible `useDeferredValue` — returns the value as-is.
|
|
345
|
+
*
|
|
346
|
+
* In Pyreon's signal-based reactivity there are no concurrent rendering lanes,
|
|
347
|
+
* so the value is never "deferred". This shim exists so Preact/React code that
|
|
348
|
+
* uses `useDeferredValue` compiles and runs without changes.
|
|
349
|
+
*/
|
|
350
|
+
export function useDeferredValue<T>(value: T): T {
|
|
351
|
+
return value
|
|
237
352
|
}
|
|
238
353
|
|
|
239
354
|
// ─── useErrorBoundary ────────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Preact-compatible API shim that runs on Pyreon's reactive engine.
|
|
5
5
|
*
|
|
6
6
|
* Provides the core Preact API surface: h, Fragment, render, hydrate,
|
|
7
|
-
* Component class, createContext, createRef, cloneElement,
|
|
8
|
-
* isValidElement,
|
|
7
|
+
* Component class, PureComponent, createContext, createRef, cloneElement,
|
|
8
|
+
* toChildArray, isValidElement, createPortal, lazy, Suspense, ErrorBoundary,
|
|
9
|
+
* and the options hook object.
|
|
9
10
|
*
|
|
10
11
|
* For hooks, import from "@pyreon/preact-compat/hooks".
|
|
11
12
|
* For signals, import from "@pyreon/preact-compat/signals".
|
|
@@ -14,8 +15,13 @@
|
|
|
14
15
|
import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
|
|
15
16
|
import {
|
|
16
17
|
createRef,
|
|
18
|
+
ErrorBoundary,
|
|
17
19
|
Fragment,
|
|
20
|
+
lazy,
|
|
21
|
+
nativeCompat,
|
|
22
|
+
Portal,
|
|
18
23
|
provide,
|
|
24
|
+
Suspense,
|
|
19
25
|
createContext as pyreonCreateContext,
|
|
20
26
|
h as pyreonH,
|
|
21
27
|
useContext,
|
|
@@ -68,6 +74,8 @@ export function createContext<T>(defaultValue: T): PreactContext<T> {
|
|
|
68
74
|
provide(ctx, props.value)
|
|
69
75
|
return props.children
|
|
70
76
|
}) as ComponentFn<{ value: T; children?: VNodeChild }>
|
|
77
|
+
// Mark as native so jsx() doesn't wrap it with wrapCompatComponent
|
|
78
|
+
nativeCompat(Provider)
|
|
71
79
|
return { ...ctx, Provider }
|
|
72
80
|
}
|
|
73
81
|
|
|
@@ -91,7 +99,14 @@ export class Component<
|
|
|
91
99
|
> {
|
|
92
100
|
props: P
|
|
93
101
|
state: S
|
|
94
|
-
|
|
102
|
+
_stateSignal: ReturnType<typeof signal<S>>
|
|
103
|
+
_lastResult?: VNodeChild
|
|
104
|
+
|
|
105
|
+
// Lifecycle methods (overridden by subclasses)
|
|
106
|
+
componentDidMount?(): void
|
|
107
|
+
componentDidUpdate?(): void
|
|
108
|
+
componentWillUnmount?(): void
|
|
109
|
+
shouldComponentUpdate?(nextProps: P, nextState: S): boolean
|
|
95
110
|
|
|
96
111
|
constructor(props: P) {
|
|
97
112
|
this.props = props
|
|
@@ -129,13 +144,25 @@ export class Component<
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
// ─── PureComponent ──────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Preact-compatible PureComponent — extends Component.
|
|
151
|
+
* In Pyreon's compat layer this behaves identically to Component
|
|
152
|
+
* (signal-based reactivity already avoids unnecessary re-renders).
|
|
153
|
+
*/
|
|
154
|
+
export class PureComponent<
|
|
155
|
+
P extends Props = Props,
|
|
156
|
+
S extends Record<string, unknown> = Record<string, unknown>,
|
|
157
|
+
> extends Component<P, S> {}
|
|
158
|
+
|
|
132
159
|
// ─── cloneElement ────────────────────────────────────────────────────────────
|
|
133
160
|
|
|
134
161
|
/**
|
|
135
162
|
* Clone a VNode with merged props (like Preact's cloneElement).
|
|
136
163
|
*/
|
|
137
164
|
export function cloneElement(vnode: VNode, props?: Props, ...children: VNodeChild[]): VNode {
|
|
138
|
-
const mergedProps = { ...vnode.props, ...
|
|
165
|
+
const mergedProps = props ? { ...vnode.props, ...props } : { ...vnode.props }
|
|
139
166
|
const mergedChildren = children.length > 0 ? children : vnode.children
|
|
140
167
|
return {
|
|
141
168
|
type: vnode.type,
|
|
@@ -185,6 +212,19 @@ export function isValidElement(x: unknown): x is VNode {
|
|
|
185
212
|
)
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
// ─── createPortal ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Preact-compatible `createPortal(children, target)`.
|
|
219
|
+
*/
|
|
220
|
+
export function createPortal(children: VNodeChild, target: Element): VNodeChild {
|
|
221
|
+
return Portal({ target, children })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Suspense / lazy / ErrorBoundary ─────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export { ErrorBoundary, lazy, Suspense }
|
|
227
|
+
|
|
188
228
|
// ─── options ─────────────────────────────────────────────────────────────────
|
|
189
229
|
|
|
190
230
|
/**
|
|
@@ -192,3 +232,7 @@ export function isValidElement(x: unknown): x is VNode {
|
|
|
192
232
|
* with libraries that check for `options._hook`, `options.vnode`, etc.
|
|
193
233
|
*/
|
|
194
234
|
export const options: Record<string, unknown> = {}
|
|
235
|
+
|
|
236
|
+
// ─── version ────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
export const version = '10.0.0-pyreon'
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
|
|
13
|
-
import { Fragment, h } from '@pyreon/core'
|
|
13
|
+
import { Fragment, h, isNativeCompat, onUnmount } from '@pyreon/core'
|
|
14
14
|
import { signal } from '@pyreon/reactivity'
|
|
15
|
+
import type { Component } from './index'
|
|
15
16
|
|
|
16
17
|
export { Fragment }
|
|
17
18
|
|
|
@@ -79,6 +80,80 @@ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
|
|
|
79
80
|
})
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// ─── Class component detection ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function isClassComponent(type: Function): boolean {
|
|
86
|
+
return type.prototype != null && typeof type.prototype.render === 'function'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Class component wrapping ───────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function wrapClassComponent(ClassComp: Function): ComponentFn {
|
|
92
|
+
const wrapped = ((props: Props) => {
|
|
93
|
+
const instance = new (ClassComp as new (props: Props) => Component)(props)
|
|
94
|
+
const version = signal(0)
|
|
95
|
+
let updateScheduled = false
|
|
96
|
+
|
|
97
|
+
// Override setState to trigger re-render via version signal
|
|
98
|
+
const origSetState = instance.setState.bind(instance)
|
|
99
|
+
instance.setState = (partial: Partial<Record<string, unknown>>) => {
|
|
100
|
+
origSetState(partial)
|
|
101
|
+
if (!updateScheduled) {
|
|
102
|
+
updateScheduled = true
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
updateScheduled = false
|
|
105
|
+
version.set(version.peek() + 1)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Override forceUpdate
|
|
111
|
+
instance.forceUpdate = () => {
|
|
112
|
+
version.set(version.peek() + 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Lifecycle: componentWillUnmount
|
|
116
|
+
let didMountFired = false
|
|
117
|
+
onUnmount(() => {
|
|
118
|
+
if (typeof instance.componentWillUnmount === 'function') {
|
|
119
|
+
instance.componentWillUnmount()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Return reactive accessor for re-renders
|
|
124
|
+
return () => {
|
|
125
|
+
const ver = version() // track for re-renders
|
|
126
|
+
instance.props = props // update props on re-render
|
|
127
|
+
|
|
128
|
+
// shouldComponentUpdate only applies after mount (ver > 0 means setState/forceUpdate)
|
|
129
|
+
if (didMountFired && ver > 0 && typeof instance.shouldComponentUpdate === 'function') {
|
|
130
|
+
if (!instance.shouldComponentUpdate(props, instance.state)) {
|
|
131
|
+
return instance._lastResult // skip render
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = instance.render()
|
|
136
|
+
instance._lastResult = result
|
|
137
|
+
|
|
138
|
+
// componentDidMount fires once after the initial render settles
|
|
139
|
+
if (!didMountFired) {
|
|
140
|
+
didMountFired = true
|
|
141
|
+
if (typeof instance.componentDidMount === 'function') {
|
|
142
|
+
queueMicrotask(() => instance.componentDidMount!())
|
|
143
|
+
}
|
|
144
|
+
} else if (ver > 0) {
|
|
145
|
+
// componentDidUpdate only fires on explicit re-renders (setState/forceUpdate)
|
|
146
|
+
if (typeof instance.componentDidUpdate === 'function') {
|
|
147
|
+
queueMicrotask(() => instance.componentDidUpdate!())
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
}) as unknown as ComponentFn
|
|
154
|
+
return wrapped
|
|
155
|
+
}
|
|
156
|
+
|
|
82
157
|
// ─── Component wrapping ──────────────────────────────────────────────────────
|
|
83
158
|
|
|
84
159
|
const _wrapperCache = new WeakMap<Function, ComponentFn>()
|
|
@@ -87,6 +162,13 @@ function wrapCompatComponent(preactComponent: Function): ComponentFn {
|
|
|
87
162
|
let wrapped = _wrapperCache.get(preactComponent)
|
|
88
163
|
if (wrapped) return wrapped
|
|
89
164
|
|
|
165
|
+
// Handle class components (those with prototype.render)
|
|
166
|
+
if (isClassComponent(preactComponent)) {
|
|
167
|
+
wrapped = wrapClassComponent(preactComponent)
|
|
168
|
+
_wrapperCache.set(preactComponent, wrapped)
|
|
169
|
+
return wrapped
|
|
170
|
+
}
|
|
171
|
+
|
|
90
172
|
// The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
|
|
91
173
|
// mountChild treats as a reactive expression via mountReactive.
|
|
92
174
|
wrapped = ((props: Props) => {
|
|
@@ -112,6 +194,17 @@ function wrapCompatComponent(preactComponent: Function): ComponentFn {
|
|
|
112
194
|
})
|
|
113
195
|
}
|
|
114
196
|
|
|
197
|
+
// Register cleanup for all hooks on unmount
|
|
198
|
+
onUnmount(() => {
|
|
199
|
+
ctx.unmounted = true
|
|
200
|
+
for (const hook of ctx.hooks) {
|
|
201
|
+
if (hook && typeof hook === 'object' && 'cleanup' in hook) {
|
|
202
|
+
const entry = hook as EffectEntry
|
|
203
|
+
if (typeof entry.cleanup === 'function') entry.cleanup()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
115
208
|
// Return reactive accessor — Pyreon's mountChild calls mountReactive
|
|
116
209
|
return () => {
|
|
117
210
|
version() // tracked read — triggers re-execution when state changes
|
|
@@ -143,15 +236,65 @@ export function jsx(
|
|
|
143
236
|
const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
|
|
144
237
|
|
|
145
238
|
if (typeof type === 'function') {
|
|
239
|
+
const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
|
|
240
|
+
// Native Pyreon components (context Provider, RouterView, QueryClientProvider,
|
|
241
|
+
// etc.) skip compat wrapping — see `@pyreon/core`'s `nativeCompat()` for the
|
|
242
|
+
// full contract.
|
|
243
|
+
if (isNativeCompat(type)) {
|
|
244
|
+
return h(type as ComponentFn, componentProps)
|
|
245
|
+
}
|
|
146
246
|
// Wrap Preact-style component for re-render support
|
|
147
247
|
const wrapped = wrapCompatComponent(type)
|
|
148
|
-
const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
|
|
149
248
|
return h(wrapped, componentProps)
|
|
150
249
|
}
|
|
151
250
|
|
|
152
251
|
// DOM element or symbol (Fragment): children go in vnode.children
|
|
153
252
|
const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
|
|
154
253
|
|
|
254
|
+
// Map Preact-style attributes to standard HTML attributes
|
|
255
|
+
if (typeof type === 'string') {
|
|
256
|
+
if (propsWithKey.className !== undefined) {
|
|
257
|
+
propsWithKey.class = propsWithKey.className
|
|
258
|
+
delete propsWithKey.className
|
|
259
|
+
}
|
|
260
|
+
if (propsWithKey.htmlFor !== undefined) {
|
|
261
|
+
propsWithKey.for = propsWithKey.htmlFor
|
|
262
|
+
delete propsWithKey.htmlFor
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Preact's onChange fires on every keystroke for form elements (like onInput)
|
|
266
|
+
if (
|
|
267
|
+
(type === 'input' || type === 'textarea' || type === 'select') &&
|
|
268
|
+
propsWithKey.onChange !== undefined
|
|
269
|
+
) {
|
|
270
|
+
if (propsWithKey.onInput === undefined) {
|
|
271
|
+
propsWithKey.onInput = propsWithKey.onChange
|
|
272
|
+
}
|
|
273
|
+
delete propsWithKey.onChange
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// autoFocus → autofocus
|
|
277
|
+
if (propsWithKey.autoFocus !== undefined) {
|
|
278
|
+
propsWithKey.autofocus = propsWithKey.autoFocus
|
|
279
|
+
delete propsWithKey.autoFocus
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// defaultValue / defaultChecked → value / checked when no controlled value
|
|
283
|
+
if (type === 'input' || type === 'textarea') {
|
|
284
|
+
if (propsWithKey.defaultValue !== undefined && propsWithKey.value === undefined) {
|
|
285
|
+
propsWithKey.value = propsWithKey.defaultValue
|
|
286
|
+
delete propsWithKey.defaultValue
|
|
287
|
+
}
|
|
288
|
+
if (propsWithKey.defaultChecked !== undefined && propsWithKey.checked === undefined) {
|
|
289
|
+
propsWithKey.checked = propsWithKey.defaultChecked
|
|
290
|
+
delete propsWithKey.defaultChecked
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Strip Preact-only props that have no DOM equivalent
|
|
295
|
+
delete propsWithKey.suppressHydrationWarning
|
|
296
|
+
}
|
|
297
|
+
|
|
155
298
|
return h(type, propsWithKey, ...(childArray as VNodeChild[]))
|
|
156
299
|
}
|
|
157
300
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mountInBrowser } from '@pyreon/test-utils/browser'
|
|
3
|
+
import { createElement, Fragment } from './index'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Real-browser smoke test for `@pyreon/preact-compat`.
|
|
7
|
+
*
|
|
8
|
+
* Per the test-environment-parity rule (`pyreon/require-browser-smoke-test`),
|
|
9
|
+
* every browser-categorized package must ship at least one
|
|
10
|
+
* `*.browser.test.*` file. This catches regressions that happy-dom unit
|
|
11
|
+
* tests can hide: importing the public API and mounting through Preact's
|
|
12
|
+
* `h` shim in real Chromium.
|
|
13
|
+
*/
|
|
14
|
+
describe('@pyreon/preact-compat — browser smoke', () => {
|
|
15
|
+
it('mounts a Preact-style element via createElement', () => {
|
|
16
|
+
const vnode = createElement('div', { id: 'preact', class: 'shim' }, 'hello, preact')
|
|
17
|
+
const { container, unmount } = mountInBrowser(vnode)
|
|
18
|
+
const el = container.querySelector('#preact')!
|
|
19
|
+
expect(el.textContent).toBe('hello, preact')
|
|
20
|
+
expect(el.classList.contains('shim')).toBe(true)
|
|
21
|
+
unmount()
|
|
22
|
+
expect(document.getElementById('preact')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('mounts a Fragment with multiple children', () => {
|
|
26
|
+
const vnode = createElement(
|
|
27
|
+
Fragment,
|
|
28
|
+
null,
|
|
29
|
+
createElement('span', { id: 'a' }, 'A'),
|
|
30
|
+
createElement('span', { id: 'b' }, 'B'),
|
|
31
|
+
)
|
|
32
|
+
const { container, unmount } = mountInBrowser(vnode)
|
|
33
|
+
expect(container.querySelector('#a')?.textContent).toBe('A')
|
|
34
|
+
expect(container.querySelector('#b')?.textContent).toBe('B')
|
|
35
|
+
unmount()
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/signals.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
batch as pyreonBatch,
|
|
11
11
|
computed as pyreonComputed,
|
|
12
12
|
effect as pyreonEffect,
|
|
13
|
+
runUntracked as pyreonRunUntracked,
|
|
13
14
|
signal as pyreonSignal,
|
|
14
15
|
} from '@pyreon/reactivity'
|
|
15
16
|
|
|
@@ -63,8 +64,7 @@ export function computed<T>(fn: () => T): ReadonlySignal<T> {
|
|
|
63
64
|
return c()
|
|
64
65
|
},
|
|
65
66
|
peek(): T {
|
|
66
|
-
|
|
67
|
-
return c()
|
|
67
|
+
return pyreonRunUntracked(() => c())
|
|
68
68
|
},
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
// See `react-compat/src/tests/native-marker-bypass.test.tsx` for the full
|
|
9
|
+
// rationale + bisect-verification notes — same shape, four parallel files
|
|
10
|
+
// (one per compat layer) so a regression in any one doesn't go unnoticed.
|
|
11
|
+
//
|
|
12
|
+
// Bisect-verified per file: removing the `if (isNativeCompat(type))` branch
|
|
13
|
+
// from preact-compat's jsx-runtime causes test #1 to fail with
|
|
14
|
+
// `expected [Function wrapped] to be [Function Native]`.
|
|
15
|
+
|
|
16
|
+
function container(): HTMLElement {
|
|
17
|
+
const el = document.createElement('div')
|
|
18
|
+
document.body.appendChild(el)
|
|
19
|
+
return el
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('preact-compat — nativeCompat() marker bypass', () => {
|
|
23
|
+
it('jsx() routes marked components through h() directly (no wrapper)', () => {
|
|
24
|
+
const Native = (props: { children?: unknown }) => h('div', null, props.children as never)
|
|
25
|
+
nativeCompat(Native)
|
|
26
|
+
|
|
27
|
+
const vnode = jsx(Native, {})
|
|
28
|
+
|
|
29
|
+
expect(vnode.type).toBe(Native)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('jsx() wraps UNMARKED components (control — bypass is selective)', () => {
|
|
33
|
+
const Unmarked = (props: { children?: unknown }) => h('div', null, props.children as never)
|
|
34
|
+
|
|
35
|
+
const vnode = jsx(Unmarked, {})
|
|
36
|
+
|
|
37
|
+
expect(vnode.type).not.toBe(Unmarked)
|
|
38
|
+
expect(typeof vnode.type).toBe('function')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('marked Provider mounts inside Pyreon setup frame — provide() reaches descendants', () => {
|
|
42
|
+
const Ctx = createContext<string>('default')
|
|
43
|
+
|
|
44
|
+
const Provider: ComponentFn = (props) => {
|
|
45
|
+
provide(Ctx, props.value as string)
|
|
46
|
+
return props.children as never
|
|
47
|
+
}
|
|
48
|
+
nativeCompat(Provider)
|
|
49
|
+
|
|
50
|
+
const Consumer: ComponentFn = () => {
|
|
51
|
+
const value = useContext(Ctx)
|
|
52
|
+
return h('span', { 'data-value': value }, value)
|
|
53
|
+
}
|
|
54
|
+
nativeCompat(Consumer)
|
|
55
|
+
|
|
56
|
+
const el = container()
|
|
57
|
+
mount(jsx(Provider, { value: 'native', children: jsx(Consumer, {}) }), el)
|
|
58
|
+
|
|
59
|
+
const span = el.querySelector('span')
|
|
60
|
+
expect(span?.getAttribute('data-value')).toBe('native')
|
|
61
|
+
expect(span?.textContent).toBe('native')
|
|
62
|
+
})
|
|
63
|
+
})
|