@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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/lib/_chunks/jsx-runtime-Cbf3f1f5.js +159 -0
- package/lib/_chunks/jsx-runtime-Cbf3f1f5.js.map +1 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +316 -0
- package/lib/index.js.map +1 -0
- package/lib/jsx-runtime.js +3 -0
- package/lib/store.js +3 -0
- package/lib/types/index.d.ts +106 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/jsx-runtime.d.ts +10 -0
- package/lib/types/jsx-runtime.d.ts.map +1 -0
- package/lib/types/store.d.ts +56 -0
- package/lib/types/store.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +538 -0
- package/src/jsx-dev-runtime.ts +1 -0
- package/src/jsx-runtime.ts +316 -0
- package/src/store.ts +26 -0
- package/src/svelte-compat.browser.test.ts +67 -0
- package/src/tests/child-instance-leak-repro.test.ts +123 -0
- package/src/tests/lifecycle-cleanup-leak-repro.test.ts +81 -0
- package/src/tests/native-marker-bypass.test.tsx +72 -0
- package/src/tests/setup.ts +3 -0
- package/src/tests/store-entry.test.ts +36 -0
- package/src/tests/svelte-compat.test.ts +547 -0
|
@@ -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
|
+
})
|