@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/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
- ctx.hooks.push(typeof initial === 'function' ? (initial as () => T)() : initial)
47
- }
48
-
49
- const value = ctx.hooks[idx] as T
50
- const setter = (v: T | ((prev: T) => T)) => {
51
- const current = ctx.hooks[idx] as T
52
- const next = typeof v === 'function' ? (v as (prev: T) => T)(current) : v
53
- if (Object.is(current, next)) return
54
- ctx.hooks[idx] = next
55
- ctx.scheduleRerender()
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
- return [value, setter]
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
- initial: S | (() => S),
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
- ctx.hooks.push(typeof initial === 'function' ? (initial as () => S)() : initial)
172
- }
173
-
174
- const state = ctx.hooks[idx] as S
175
- const dispatch = (action: A) => {
176
- const current = ctx.hooks[idx] as S
177
- const next = reducer(current, action)
178
- if (Object.is(current, next)) return
179
- ctx.hooks[idx] = next
180
- ctx.scheduleRerender()
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
- return [state, dispatch]
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
- areEqual ??
216
- ((a: P, b: P) => {
217
- const keysA = Object.keys(a)
218
- const keysB = Object.keys(b)
219
- if (keysA.length !== keysB.length) return false
220
- for (const k of keysA) {
221
- if (!Object.is(a[k], b[k])) return false
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
- return true
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
- let prevProps: P | null = null
227
- let prevResult: VNodeChild = null
283
+ // ─── forwardRef ─────────────────────────────────────────────────────────────
228
284
 
229
- return (props: P) => {
230
- if (prevProps !== null && compare(prevProps, props)) {
231
- return prevResult
232
- }
233
- prevProps = props
234
- prevResult = (component as (p: P) => VNodeChild)(props)
235
- return prevResult
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, toChildArray,
8
- * isValidElement, and the options hook object.
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
- private _stateSignal: ReturnType<typeof signal<S>>
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, ...(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'
@@ -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
- // computed doesn't have peek — just read the value untracked
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
+ })