@pyreon/svelte-compat 0.17.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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Compat JSX runtime for Svelte compatibility mode.
3
+ *
4
+ * When `jsxImportSource` is redirected to `@pyreon/svelte-compat` (via the vite
5
+ * plugin's `compat: "svelte"` option), OXC rewrites JSX to import from this file.
6
+ *
7
+ * For component VNodes, we wrap the component function so it returns a reactive
8
+ * accessor — enabling Svelte-store-driven re-renders on state change while Pyreon's
9
+ * existing renderer handles all DOM work.
10
+ *
11
+ * The component body runs inside `runUntracked` to prevent signal reads (from
12
+ * createSignal getters) from being tracked by the reactive accessor. Only the
13
+ * version signal triggers re-renders.
14
+ *
15
+ * ## Child instance preservation
16
+ *
17
+ * When a parent component re-renders, mountReactive does a full teardown+rebuild
18
+ * of the DOM tree. Without preservation, child components get brand new
19
+ * RenderContexts with empty hooks arrays — causing `onMount` and `onCleanup`
20
+ * to fire again, which can trigger infinite re-render loops.
21
+ *
22
+ * To fix this, we store child RenderContexts in the parent's hooks array (indexed
23
+ * by the parent's hook counter). When the child wrapper is called again after a
24
+ * parent re-render, it reuses the existing ctx (preserving hooks state), so
25
+ * hook-indexed guards like `if (idx >= ctx.hooks.length) return` work correctly
26
+ * and lifecycle hooks don't re-fire.
27
+ */
28
+
29
+ import type { ComponentFn, Props, VNode, VNodeChild } from '@pyreon/core'
30
+ import {
31
+ ErrorBoundary,
32
+ For,
33
+ Fragment,
34
+ h,
35
+ isNativeCompat,
36
+ Match,
37
+ onUnmount,
38
+ Show,
39
+ Suspense,
40
+ Switch,
41
+ } from '@pyreon/core'
42
+ import { runUntracked, signal } from '@pyreon/reactivity'
43
+
44
+ export { Fragment }
45
+
46
+ // ─── Render context (used by hooks) ──────────────────────────────────────────
47
+
48
+ export interface RenderContext {
49
+ hooks: unknown[]
50
+ scheduleRerender: () => void
51
+ /** Effect entries pending execution after render */
52
+ pendingEffects: EffectEntry[]
53
+ /** Layout effect entries pending execution after render */
54
+ pendingLayoutEffects: EffectEntry[]
55
+ /** Set to true when the component is unmounted */
56
+ unmounted: boolean
57
+ /** Callbacks to run on unmount (lifecycle + effect cleanups) */
58
+ unmountCallbacks: (() => void)[]
59
+ /** Current component props — read by createEventDispatcher() */
60
+ props?: Record<string, unknown>
61
+ }
62
+
63
+ export interface EffectEntry {
64
+ fn: () => (() => void) | void
65
+ deps: unknown[] | undefined
66
+ cleanup: (() => void) | undefined
67
+ }
68
+
69
+ let _currentCtx: RenderContext | null = null
70
+ let _hookIndex = 0
71
+
72
+ export function getCurrentCtx(): RenderContext | null {
73
+ return _currentCtx
74
+ }
75
+
76
+ export function getHookIndex(): number {
77
+ return _hookIndex++
78
+ }
79
+
80
+ export function beginRender(ctx: RenderContext): void {
81
+ _currentCtx = ctx
82
+ _hookIndex = 0
83
+ ctx.pendingEffects = []
84
+ ctx.pendingLayoutEffects = []
85
+ }
86
+
87
+ export function endRender(): void {
88
+ _currentCtx = null
89
+ _hookIndex = 0
90
+ }
91
+
92
+ // ─── Effect runners ──────────────────────────────────────────────────────────
93
+
94
+ function runLayoutEffects(entries: EffectEntry[]): void {
95
+ for (const entry of entries) {
96
+ if (entry.cleanup) entry.cleanup()
97
+ const cleanup = entry.fn()
98
+ entry.cleanup = typeof cleanup === 'function' ? cleanup : undefined
99
+ }
100
+ }
101
+
102
+ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
103
+ if (entries.length === 0) return
104
+ queueMicrotask(() => {
105
+ for (const entry of entries) {
106
+ if (ctx.unmounted) return
107
+ if (entry.cleanup) entry.cleanup()
108
+ const cleanup = entry.fn()
109
+ entry.cleanup = typeof cleanup === 'function' ? cleanup : undefined
110
+ }
111
+ })
112
+ }
113
+
114
+ // ─── Child instance preservation ─────────────────────────────────────────────
115
+
116
+ /** Stored in the parent's hooks array to preserve child state across re-renders */
117
+ interface ChildInstance {
118
+ ctx: RenderContext
119
+ version: ReturnType<typeof signal<number>>
120
+ updateScheduled: boolean
121
+ }
122
+
123
+ // Internal prop keys for passing parent context info to child wrappers
124
+ const _CHILD_INSTANCE = Symbol.for('pyreon.childInstance')
125
+ const noop = () => {
126
+ /* noop */
127
+ }
128
+
129
+ // ─── Component wrapping ──────────────────────────────────────────────────────
130
+
131
+ const _wrapperCache = new WeakMap<Function, ComponentFn>()
132
+
133
+ // Pyreon core components that must NOT be wrapped — they rely on internal reactivity
134
+ const _nativeComponents: Set<Function> = new Set([
135
+ Show,
136
+ For,
137
+ Switch,
138
+ Match,
139
+ Suspense,
140
+ ErrorBoundary,
141
+ ])
142
+
143
+ function wrapCompatComponent(solidComponent: Function): ComponentFn {
144
+ if (_nativeComponents.has(solidComponent)) return solidComponent as ComponentFn
145
+
146
+ let wrapped = _wrapperCache.get(solidComponent)
147
+ if (wrapped) return wrapped
148
+
149
+ // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
150
+ // mountChild treats as a reactive expression via mountReactive.
151
+ wrapped = ((props: Props) => {
152
+ // Check for a preserved child instance from the parent's hooks
153
+ const existing = (props as Record<symbol, unknown>)[_CHILD_INSTANCE] as
154
+ | ChildInstance
155
+ | undefined
156
+
157
+ const ctx: RenderContext = existing?.ctx ?? {
158
+ hooks: [],
159
+ scheduleRerender: () => {
160
+ // Will be replaced below after version signal is created
161
+ },
162
+ pendingEffects: [],
163
+ pendingLayoutEffects: [],
164
+ unmounted: false,
165
+ unmountCallbacks: [],
166
+ }
167
+
168
+ // When reusing an existing ctx after parent re-render, reset unmounted flag
169
+ // and clear stale unmount callbacks (they belong to the previous mount cycle)
170
+ if (existing) {
171
+ ctx.unmounted = false
172
+ ctx.unmountCallbacks = []
173
+ }
174
+
175
+ const version = existing?.version ?? signal(0)
176
+
177
+ // Use a shared updateScheduled flag (preserved across parent re-renders)
178
+ let updateScheduled = existing?.updateScheduled ?? false
179
+
180
+ ctx.scheduleRerender = () => {
181
+ if (ctx.unmounted || updateScheduled) return
182
+ updateScheduled = true
183
+ queueMicrotask(() => {
184
+ updateScheduled = false
185
+ if (!ctx.unmounted) version.set(version.peek() + 1)
186
+ })
187
+ }
188
+
189
+ // Register cleanup when component unmounts
190
+ onUnmount(() => {
191
+ ctx.unmounted = true
192
+ for (const cb of ctx.unmountCallbacks) cb()
193
+ })
194
+
195
+ // Strip the internal prop before passing to the component
196
+ const { [_CHILD_INSTANCE]: _stripped, ...cleanProps } = props as Record<
197
+ string | symbol,
198
+ unknown
199
+ >
200
+
201
+ // Expose props on the ctx so createEventDispatcher() can forward
202
+ // child events to the parent's on<Type> / on:<type> prop.
203
+ ctx.props = cleanProps as Record<string, unknown>
204
+
205
+ // Return reactive accessor — Pyreon's mountChild calls mountReactive
206
+ return () => {
207
+ version() // tracked read — triggers re-execution when state changes
208
+ beginRender(ctx)
209
+ // runUntracked prevents signal reads (from createSignal getters) from
210
+ // being tracked by this accessor — only the version signal should trigger re-renders
211
+ const result = runUntracked(() => (solidComponent as ComponentFn)(cleanProps as Props))
212
+ const layoutEffects = ctx.pendingLayoutEffects
213
+ const effects = ctx.pendingEffects
214
+ endRender()
215
+
216
+ runLayoutEffects(layoutEffects)
217
+ scheduleEffects(ctx, effects)
218
+
219
+ return result
220
+ }
221
+ }) as unknown as ComponentFn
222
+
223
+ // Forward __loading from lazy components so Pyreon's Suspense can detect them
224
+ if ('__loading' in solidComponent) {
225
+ ;(wrapped as unknown as Record<string, unknown>).__loading = (
226
+ solidComponent as unknown as Record<string, unknown>
227
+ ).__loading
228
+ }
229
+
230
+ _wrapperCache.set(solidComponent, wrapped)
231
+ return wrapped
232
+ }
233
+
234
+ // ─── Child instance lookup ───────────────────────────────────────────────────
235
+
236
+ function createChildInstance(): ChildInstance {
237
+ return {
238
+ ctx: {
239
+ hooks: [],
240
+ scheduleRerender: noop,
241
+ pendingEffects: [],
242
+ pendingLayoutEffects: [],
243
+ unmounted: false,
244
+ unmountCallbacks: [],
245
+ },
246
+ version: signal(0),
247
+ updateScheduled: false,
248
+ }
249
+ }
250
+
251
+ /**
252
+ * During a parent component render, get or create the child instance at the
253
+ * current hook index. Returns undefined when called outside a component render.
254
+ */
255
+ function resolveChildInstance(): ChildInstance | undefined {
256
+ const parentCtx = _currentCtx
257
+ if (!parentCtx) return undefined
258
+
259
+ const idx = _hookIndex++
260
+ if (idx < parentCtx.hooks.length) {
261
+ return parentCtx.hooks[idx] as ChildInstance
262
+ }
263
+ const instance = createChildInstance()
264
+ parentCtx.hooks[idx] = instance
265
+ return instance
266
+ }
267
+
268
+ // ─── JSX functions ───────────────────────────────────────────────────────────
269
+
270
+ export function jsx(
271
+ type: string | ComponentFn | symbol,
272
+ props: Props & { children?: VNodeChild | VNodeChild[] },
273
+ key?: string | number | null,
274
+ ): VNode {
275
+ const { children, ...rest } = props
276
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
277
+
278
+ if (typeof type === 'function') {
279
+ // Defense-in-depth: hardcoded set of Pyreon core control-flow primitives
280
+ // that are always native (kept even after the marker convergence — these
281
+ // are imported into solid-compat directly, so guarding their identity
282
+ // doesn't cost a property lookup and ensures the marker is never lost
283
+ // through any tree-shaking edge case).
284
+ if (_nativeComponents.has(type)) {
285
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
286
+ return h(type as ComponentFn, componentProps)
287
+ }
288
+
289
+ // Native Pyreon framework components (context Providers, RouterView, etc.)
290
+ // skip compat wrapping — see `@pyreon/core`'s `nativeCompat()` for the
291
+ // full contract.
292
+ if (isNativeCompat(type)) {
293
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
294
+ return h(type as ComponentFn, componentProps)
295
+ }
296
+
297
+ const wrapped = wrapCompatComponent(type)
298
+ const componentProps =
299
+ children !== undefined ? { ...propsWithKey, children } : { ...propsWithKey }
300
+
301
+ const childInstance = resolveChildInstance()
302
+ if (childInstance) {
303
+ ;(componentProps as Record<symbol, unknown>)[_CHILD_INSTANCE] = childInstance
304
+ }
305
+
306
+ return h(wrapped, componentProps)
307
+ }
308
+
309
+ // DOM element or symbol (Fragment): children go in vnode.children
310
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
311
+
312
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
313
+ }
314
+
315
+ export const jsxs = jsx
316
+ export const jsxDEV = jsx
package/src/store.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@pyreon/svelte-compat/store` — the `svelte/store` import surface.
3
+ *
4
+ * Svelte code does `import { writable } from 'svelte/store'`; the vite
5
+ * plugin's `compat: 'svelte'` aliases that specifier to this entry.
6
+ * Re-exports only the store API (not lifecycle/context) so the subpath
7
+ * mirrors Svelte's real `svelte/store` shape; the everything-entry is
8
+ * `@pyreon/svelte-compat` (`./index`).
9
+ */
10
+
11
+ export {
12
+ derived,
13
+ get,
14
+ readable,
15
+ readonly,
16
+ writable,
17
+ } from './index'
18
+ export type {
19
+ Invalidator,
20
+ Readable,
21
+ StartStopNotifier,
22
+ Subscriber,
23
+ Unsubscriber,
24
+ Updater,
25
+ Writable,
26
+ } from './index'
@@ -0,0 +1,67 @@
1
+ import { h } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { derived, get, readable, writable } from './index'
5
+
6
+ /**
7
+ * Real-browser smoke test for `@pyreon/svelte-compat`.
8
+ *
9
+ * Per the test-environment-parity rule (`pyreon/require-browser-smoke-test`),
10
+ * every browser-categorized package must ship at least one
11
+ * `*.browser.test.*` file. This catches regressions that happy-dom unit
12
+ * tests can hide: importing the public API and exercising the Svelte
13
+ * store contract end-to-end in real Chromium, including a store-driven
14
+ * DOM mount.
15
+ */
16
+ describe('@pyreon/svelte-compat — browser smoke', () => {
17
+ it('writable round-trips set/update + subscribe', () => {
18
+ const count = writable(0)
19
+ const seen: number[] = []
20
+ const unsub = count.subscribe((v) => seen.push(v))
21
+ count.set(7)
22
+ count.update((n) => n + 1)
23
+ unsub()
24
+ count.set(99) // ignored — unsubscribed
25
+ expect(seen).toEqual([0, 7, 8])
26
+ expect(get(count)).toBe(99)
27
+ })
28
+
29
+ it('derived recomputes from source stores', () => {
30
+ const a = writable(2)
31
+ const b = writable(3)
32
+ const sum = derived([a, b], ([x, y]: [number, number]) => x + y)
33
+ expect(get(sum)).toBe(5)
34
+ a.set(10)
35
+ expect(get(sum)).toBe(13)
36
+ })
37
+
38
+ it('readable start/stop notifier fires on 0→1 / 1→0', () => {
39
+ let started = 0
40
+ let stopped = 0
41
+ const store = readable(1, () => {
42
+ started++
43
+ return () => {
44
+ stopped++
45
+ }
46
+ })
47
+ const u1 = store.subscribe(() => {})
48
+ const u2 = store.subscribe(() => {})
49
+ expect(started).toBe(1) // only on 0→1
50
+ u1()
51
+ expect(stopped).toBe(0) // still 1 subscriber
52
+ u2()
53
+ expect(stopped).toBe(1) // 1→0
54
+ })
55
+
56
+ it('mounts a store-driven element in real browser + cleans up', () => {
57
+ const label = writable('svelte-compat')
58
+ let current = ''
59
+ label.subscribe((v) => (current = v))
60
+ const vnode = h('div', { id: 'svelte-compat' }, current)
61
+ const { container, unmount } = mountInBrowser(vnode)
62
+ const el = container.querySelector('#svelte-compat')!
63
+ expect(el.textContent).toBe('svelte-compat')
64
+ unmount()
65
+ expect(document.getElementById('svelte-compat')).toBeNull()
66
+ })
67
+ })
@@ -0,0 +1,123 @@
1
+ /**
2
+ * REPRODUCTION + REGRESSION — `writable.subscribe()` from a child
3
+ * component's body, combined with the parent-re-render
4
+ * ChildInstance-preservation in `jsx-runtime.ts:170-173`, leaked one
5
+ * store subscriber per parent re-render cycle.
6
+ *
7
+ * Pre-fix flow:
8
+ * 1. First render: `subscribe(handler)` pushes its unsub into
9
+ * `ctx.unmountCallbacks` and caches `{unsub}` at `ctx.hooks[idx]`.
10
+ * 2. Parent re-renders → wrapper sees the cached ChildInstance →
11
+ * `ctx.unmountCallbacks = []` RESETS the array (the wrapper's
12
+ * cycle-N callbacks are stale and need to be dropped before
13
+ * cycle-N+1 begins).
14
+ * 3. Child re-runs → `subscribe(handler)` hits the cached fast path
15
+ * `if (cached) { run(v); return cached.unsub }` → **does NOT
16
+ * re-push the cached unsub** into the new (empty) unmountCallbacks.
17
+ * 4. Component eventually unmounts → unmountCallbacks loop runs over
18
+ * an array missing the original unsub → the store's internal
19
+ * `subs.Set` keeps the subscriber forever.
20
+ *
21
+ * Class D event-listener pile-up shape (the subscriber set IS the
22
+ * listener set). Linear growth per parent re-render cycle.
23
+ */
24
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
25
+ import type { RenderContext } from '../jsx-runtime'
26
+ import { beginRender, endRender } from '../jsx-runtime'
27
+ import { writable } from '../index'
28
+
29
+ describe('svelte-compat — writable.subscribe survives ChildInstance preservation', () => {
30
+ let ctx: RenderContext
31
+
32
+ beforeEach(() => {
33
+ ctx = {
34
+ hooks: [],
35
+ scheduleRerender: () => {},
36
+ pendingEffects: [],
37
+ pendingLayoutEffects: [],
38
+ unmounted: false,
39
+ unmountCallbacks: [],
40
+ }
41
+ })
42
+ afterEach(() => {
43
+ // Make sure no test leaves a render in progress that could leak
44
+ // into the next test's ctx.
45
+ try {
46
+ endRender()
47
+ } catch {
48
+ // already ended
49
+ }
50
+ })
51
+
52
+ it('REGRESSION: cached subscribe re-attaches its unsub after unmountCallbacks reset', () => {
53
+ const store = writable(0)
54
+ // Reach into the store's internal subscriber set via a probe
55
+ // subscriber that we can count after teardown.
56
+ const observed: number[] = []
57
+
58
+ // First render — calls subscribe(handler) once, registers in
59
+ // unmountCallbacks.
60
+ beginRender(ctx)
61
+ store.subscribe((v) => observed.push(v))
62
+ endRender()
63
+ expect(ctx.unmountCallbacks.length).toBe(1)
64
+
65
+ // Parent re-render — wrapper resets unmountCallbacks to []
66
+ // (mirroring jsx-runtime.ts:172). The cached hook at index 0
67
+ // still has the unsub.
68
+ ctx.unmountCallbacks = []
69
+
70
+ // Child re-runs subscribe(handler) — hits the cached path.
71
+ beginRender(ctx)
72
+ store.subscribe((v) => observed.push(v))
73
+ endRender()
74
+
75
+ // The critical assertion: the cached unsub MUST be back in
76
+ // unmountCallbacks after the cached fast path fires. Pre-fix the
77
+ // array stays empty.
78
+ expect(ctx.unmountCallbacks.length).toBe(1)
79
+
80
+ // Simulate unmount — the unmountCallbacks loop runs.
81
+ for (const cb of ctx.unmountCallbacks) cb()
82
+
83
+ // Post-unmount, the store should have no remaining subscribers.
84
+ // Write to the store — if the subscription survived the unmount,
85
+ // observed.length would increment.
86
+ const beforeCount = observed.length
87
+ store.set(42)
88
+ expect(observed.length).toBe(beforeCount)
89
+ })
90
+
91
+ it('REGRESSION: 10 parent re-render cycles do NOT accumulate subscribers', () => {
92
+ const store = writable(0)
93
+ const observed: number[] = []
94
+
95
+ // First render registers the subscription.
96
+ beginRender(ctx)
97
+ store.subscribe((v) => observed.push(v))
98
+ endRender()
99
+
100
+ // 10 parent re-render cycles. Each one resets unmountCallbacks
101
+ // then runs the child's subscribe call again (cached path).
102
+ for (let i = 0; i < 10; i++) {
103
+ ctx.unmountCallbacks = []
104
+ beginRender(ctx)
105
+ store.subscribe((v) => observed.push(v))
106
+ endRender()
107
+ }
108
+
109
+ // After 10 cycles the unmountCallbacks array should still have
110
+ // exactly ONE entry (the same unsub from the first registration).
111
+ // Pre-fix it has 0 (every cycle reset, none re-pushed) so the
112
+ // unmount cleanup never fires.
113
+ expect(ctx.unmountCallbacks.length).toBe(1)
114
+
115
+ // Unmount cleans up.
116
+ for (const cb of ctx.unmountCallbacks) cb()
117
+
118
+ const beforeCount = observed.length
119
+ store.set(99)
120
+ // Post-unmount the subscriber must be gone — no further writes.
121
+ expect(observed.length).toBe(beforeCount)
122
+ })
123
+ })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * REPRODUCTION + REGRESSION — `onMount`-returned cleanup + `onDestroy`
3
+ * callbacks survive ChildInstance preservation across a parent re-render.
4
+ *
5
+ * The lifecycle sibling of the #739 `writable.subscribe` re-push bug:
6
+ *
7
+ * 1. First render: `onMount`/`onDestroy` push their cleanup into
8
+ * `ctx.unmountCallbacks` AND store it at `ctx.hooks[idx]` (hook-indexed,
9
+ * once).
10
+ * 2. Parent re-render preserves the ChildInstance and the wrapper resets
11
+ * `ctx.unmountCallbacks = []` (jsx-runtime.ts:172) to drop stale callbacks.
12
+ * 3. Child re-runs `onMount`/`onDestroy` → the `idx < hooks.length` (cached)
13
+ * path. Pre-fix this path did NOTHING, so the cleanup was never re-pushed.
14
+ * 4. Final unmount runs the (now-empty) `unmountCallbacks` → the `onMount`
15
+ * cleanup never runs and `onDestroy` never fires — a leaked resource per
16
+ * surviving child instance.
17
+ *
18
+ * The fix re-pushes the stored cleanup on the cached path (the same shape as
19
+ * the store-path #739 fix). This test drives a manual render context through
20
+ * first-render → reset → re-render and asserts the callbacks are restored and
21
+ * actually fire on unmount. Bisect: revert the `else`-branch re-push in
22
+ * index.ts onMount/onDestroy → the post-reset assertions fail.
23
+ */
24
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
25
+ import type { RenderContext } from '../jsx-runtime'
26
+ import { beginRender, endRender } from '../jsx-runtime'
27
+ import { onDestroy, onMount } from '../index'
28
+
29
+ describe('svelte-compat — onMount/onDestroy cleanup survives ChildInstance preservation', () => {
30
+ let ctx: RenderContext
31
+ beforeEach(() => {
32
+ ctx = {
33
+ hooks: [],
34
+ scheduleRerender: () => {},
35
+ pendingEffects: [],
36
+ pendingLayoutEffects: [],
37
+ unmounted: false,
38
+ unmountCallbacks: [],
39
+ }
40
+ })
41
+ afterEach(() => {
42
+ try {
43
+ endRender()
44
+ } catch {
45
+ // already ended
46
+ }
47
+ })
48
+
49
+ it('REGRESSION: onMount-cleanup + onDestroy re-attach after unmountCallbacks reset', () => {
50
+ let destroyRan = false
51
+
52
+ // First render — registers onMount cleanup (idx 0) + onDestroy (idx 1).
53
+ beginRender(ctx)
54
+ onMount(() => () => {})
55
+ onDestroy(() => {
56
+ destroyRan = true
57
+ })
58
+ endRender()
59
+ expect(ctx.unmountCallbacks.length).toBe(2)
60
+
61
+ // Parent re-render — wrapper resets unmountCallbacks to [] (jsx-runtime.ts:172).
62
+ // The cached hooks at idx 0/1 still hold the cleanup callbacks.
63
+ ctx.unmountCallbacks = []
64
+
65
+ // Child re-runs the lifecycle hooks — hits the cached (idx < length) path.
66
+ beginRender(ctx)
67
+ onMount(() => () => {})
68
+ onDestroy(() => {
69
+ destroyRan = true
70
+ })
71
+ endRender()
72
+
73
+ // Critical assertion: both cleanups MUST be back in unmountCallbacks after
74
+ // the cached path. Pre-fix the array stays empty (cleanups orphaned).
75
+ expect(ctx.unmountCallbacks.length).toBe(2)
76
+
77
+ // Simulate final unmount — the loop runs; onDestroy must fire.
78
+ for (const cb of ctx.unmountCallbacks) cb()
79
+ expect(destroyRan).toBe(true)
80
+ })
81
+ })
@@ -0,0 +1,72 @@
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.
10
+ //
11
+ // svelte-compat note: the jsx-runtime is the framework-agnostic compat
12
+ // wrapper (shared verbatim with solid-compat). jsx() has TWO native-
13
+ // routing paths in sequence — first the hardcoded `_nativeComponents`
14
+ // Set as defense-in-depth (Show, For, Switch, Match, Suspense,
15
+ // ErrorBoundary), then the marker check. These tests use USER-defined
16
+ // Native/Provider/Consumer (NOT in the hardcoded set), so the bypass
17
+ // MUST come from the marker path (`isNativeCompat(type)`), proving the
18
+ // marker check fires correctly.
19
+ //
20
+ // Bisect-verified per file: removing the `if (isNativeCompat(type))`
21
+ // branch from svelte-compat's jsx-runtime (while keeping the
22
+ // `_nativeComponents` set check) causes test #1 to fail with
23
+ // `expected [Function wrapped] to be [Function Native]`.
24
+
25
+ function container(): HTMLElement {
26
+ const el = document.createElement('div')
27
+ document.body.appendChild(el)
28
+ return el
29
+ }
30
+
31
+ describe('svelte-compat — nativeCompat() marker bypass', () => {
32
+ it('jsx() routes marked components through h() directly (no wrapper)', () => {
33
+ const Native = (props: { children?: unknown }) => h('div', null, props.children as never)
34
+ nativeCompat(Native)
35
+
36
+ const vnode = jsx(Native, {})
37
+
38
+ expect(vnode.type).toBe(Native)
39
+ })
40
+
41
+ it('jsx() wraps UNMARKED components (control — bypass is selective)', () => {
42
+ const Unmarked = (props: { children?: unknown }) => h('div', null, props.children as never)
43
+
44
+ const vnode = jsx(Unmarked, {})
45
+
46
+ expect(vnode.type).not.toBe(Unmarked)
47
+ expect(typeof vnode.type).toBe('function')
48
+ })
49
+
50
+ it('marked Provider mounts inside Pyreon setup frame — provide() reaches descendants', () => {
51
+ const Ctx = createContext<string>('default')
52
+
53
+ const Provider: ComponentFn = (props) => {
54
+ provide(Ctx, props.value as string)
55
+ return props.children as never
56
+ }
57
+ nativeCompat(Provider)
58
+
59
+ const Consumer: ComponentFn = () => {
60
+ const value = useContext(Ctx)
61
+ return h('span', { 'data-value': value }, value)
62
+ }
63
+ nativeCompat(Consumer)
64
+
65
+ const el = container()
66
+ mount(jsx(Provider, { value: 'native', children: jsx(Consumer, {}) }), el)
67
+
68
+ const span = el.querySelector('span')
69
+ expect(span?.getAttribute('data-value')).toBe('native')
70
+ expect(span?.textContent).toBe('native')
71
+ })
72
+ })
@@ -0,0 +1,3 @@
1
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
2
+
3
+ GlobalRegistrator.register()