@pyreon/core 0.24.4 → 0.24.6
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +53 -31
- package/package.json +2 -6
- package/src/compat-marker.ts +0 -79
- package/src/compat-shared.ts +0 -80
- package/src/component.ts +0 -98
- package/src/context.ts +0 -349
- package/src/defer.ts +0 -279
- package/src/dynamic.ts +0 -32
- package/src/env.d.ts +0 -6
- package/src/error-boundary.ts +0 -90
- package/src/for.ts +0 -51
- package/src/h.ts +0 -80
- package/src/index.ts +0 -80
- package/src/jsx-dev-runtime.ts +0 -2
- package/src/jsx-runtime.ts +0 -747
- package/src/lazy.ts +0 -25
- package/src/lifecycle.ts +0 -152
- package/src/manifest.ts +0 -579
- package/src/map-array.ts +0 -42
- package/src/portal.ts +0 -39
- package/src/props.ts +0 -269
- package/src/ref.ts +0 -32
- package/src/show.ts +0 -121
- package/src/style.ts +0 -102
- package/src/suspense.ts +0 -52
- package/src/telemetry.ts +0 -120
- package/src/tests/compat-marker.test.ts +0 -96
- package/src/tests/compat-shared.test.ts +0 -99
- package/src/tests/component.test.ts +0 -281
- package/src/tests/context.test.ts +0 -629
- package/src/tests/core.test.ts +0 -1290
- package/src/tests/cx.test.ts +0 -70
- package/src/tests/defer.test.ts +0 -359
- package/src/tests/dynamic.test.ts +0 -87
- package/src/tests/error-boundary.test.ts +0 -181
- package/src/tests/extract-props-overloads.types.test.ts +0 -135
- package/src/tests/for.test.ts +0 -117
- package/src/tests/h.test.ts +0 -221
- package/src/tests/jsx-compat.test.tsx +0 -86
- package/src/tests/lazy.test.ts +0 -100
- package/src/tests/lifecycle.test.ts +0 -350
- package/src/tests/manifest-snapshot.test.ts +0 -100
- package/src/tests/map-array.test.ts +0 -313
- package/src/tests/native-marker-error-boundary.test.ts +0 -12
- package/src/tests/portal.test.ts +0 -48
- package/src/tests/props-extended.test.ts +0 -157
- package/src/tests/props.test.ts +0 -250
- package/src/tests/reactive-context.test.ts +0 -69
- package/src/tests/reactive-props.test.ts +0 -157
- package/src/tests/ref.test.ts +0 -70
- package/src/tests/show.test.ts +0 -314
- package/src/tests/style.test.ts +0 -157
- package/src/tests/suspense.test.ts +0 -139
- package/src/tests/telemetry.test.ts +0 -297
- package/src/types.ts +0 -116
|
@@ -1,629 +0,0 @@
|
|
|
1
|
-
import { runWithHooks } from '../component'
|
|
2
|
-
import {
|
|
3
|
-
captureContextStack,
|
|
4
|
-
createContext,
|
|
5
|
-
getContextStackLength,
|
|
6
|
-
popContext,
|
|
7
|
-
provide,
|
|
8
|
-
pushContext,
|
|
9
|
-
restoreContextStack,
|
|
10
|
-
setContextStackProvider,
|
|
11
|
-
useContext,
|
|
12
|
-
withContext,
|
|
13
|
-
} from '../context'
|
|
14
|
-
import type { ContextSnapshot } from '../context'
|
|
15
|
-
import type { ComponentFn, Props } from '../types'
|
|
16
|
-
|
|
17
|
-
describe('createContext', () => {
|
|
18
|
-
test('returns context with unique symbol id', () => {
|
|
19
|
-
const ctx = createContext('default')
|
|
20
|
-
expect(typeof ctx.id).toBe('symbol')
|
|
21
|
-
expect(ctx.defaultValue).toBe('default')
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('each context has a unique id', () => {
|
|
25
|
-
const a = createContext(1)
|
|
26
|
-
const b = createContext(2)
|
|
27
|
-
expect(a.id).not.toBe(b.id)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test('undefined default value', () => {
|
|
31
|
-
const ctx = createContext<string | undefined>(undefined)
|
|
32
|
-
expect(ctx.defaultValue).toBeUndefined()
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('null default value', () => {
|
|
36
|
-
const ctx = createContext<null>(null)
|
|
37
|
-
expect(ctx.defaultValue).toBeNull()
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('object default value', () => {
|
|
41
|
-
const obj = { theme: 'dark', lang: 'en' }
|
|
42
|
-
const ctx = createContext(obj)
|
|
43
|
-
expect(ctx.defaultValue).toBe(obj)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('function default value', () => {
|
|
47
|
-
const fn = () => 42
|
|
48
|
-
const ctx = createContext(fn)
|
|
49
|
-
expect(ctx.defaultValue).toBe(fn)
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('useContext', () => {
|
|
54
|
-
test('returns default when no provider exists', () => {
|
|
55
|
-
const ctx = createContext('fallback')
|
|
56
|
-
expect(useContext(ctx)).toBe('fallback')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('returns provided value from pushContext', () => {
|
|
60
|
-
const ctx = createContext('default')
|
|
61
|
-
pushContext(new Map([[ctx.id, 'provided']]))
|
|
62
|
-
expect(useContext(ctx)).toBe('provided')
|
|
63
|
-
popContext()
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
test('returns innermost value with nested pushContext', () => {
|
|
67
|
-
const ctx = createContext('default')
|
|
68
|
-
pushContext(new Map([[ctx.id, 'outer']]))
|
|
69
|
-
pushContext(new Map([[ctx.id, 'inner']]))
|
|
70
|
-
expect(useContext(ctx)).toBe('inner')
|
|
71
|
-
popContext()
|
|
72
|
-
expect(useContext(ctx)).toBe('outer')
|
|
73
|
-
popContext()
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('different contexts in same frame are independent', () => {
|
|
77
|
-
const ctxA = createContext('a-default')
|
|
78
|
-
const ctxB = createContext('b-default')
|
|
79
|
-
const frame = new Map<symbol, unknown>([
|
|
80
|
-
[ctxA.id, 'a-value'],
|
|
81
|
-
[ctxB.id, 'b-value'],
|
|
82
|
-
])
|
|
83
|
-
pushContext(frame)
|
|
84
|
-
expect(useContext(ctxA)).toBe('a-value')
|
|
85
|
-
expect(useContext(ctxB)).toBe('b-value')
|
|
86
|
-
popContext()
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('context not in frame falls through to previous frame', () => {
|
|
90
|
-
const ctxA = createContext('a-default')
|
|
91
|
-
const ctxB = createContext('b-default')
|
|
92
|
-
pushContext(new Map([[ctxA.id, 'a-outer']]))
|
|
93
|
-
pushContext(new Map([[ctxB.id, 'b-inner']]))
|
|
94
|
-
// ctxA is not in the inner frame, should fall through to outer
|
|
95
|
-
expect(useContext(ctxA)).toBe('a-outer')
|
|
96
|
-
expect(useContext(ctxB)).toBe('b-inner')
|
|
97
|
-
popContext()
|
|
98
|
-
popContext()
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
describe('pushContext / popContext', () => {
|
|
103
|
-
test('push and pop maintain correct stack order', () => {
|
|
104
|
-
const ctx = createContext(0)
|
|
105
|
-
pushContext(new Map([[ctx.id, 1]]))
|
|
106
|
-
pushContext(new Map([[ctx.id, 2]]))
|
|
107
|
-
pushContext(new Map([[ctx.id, 3]]))
|
|
108
|
-
expect(useContext(ctx)).toBe(3)
|
|
109
|
-
popContext()
|
|
110
|
-
expect(useContext(ctx)).toBe(2)
|
|
111
|
-
popContext()
|
|
112
|
-
expect(useContext(ctx)).toBe(1)
|
|
113
|
-
popContext()
|
|
114
|
-
expect(useContext(ctx)).toBe(0) // default
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('popContext on empty stack is a silent no-op', () => {
|
|
118
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
119
|
-
popContext()
|
|
120
|
-
expect(warnSpy).not.toHaveBeenCalled()
|
|
121
|
-
warnSpy.mockRestore()
|
|
122
|
-
})
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
describe('withContext', () => {
|
|
126
|
-
test('provides value during callback execution', () => {
|
|
127
|
-
const ctx = createContext('default')
|
|
128
|
-
let captured = ''
|
|
129
|
-
withContext(ctx, 'inside', () => {
|
|
130
|
-
captured = useContext(ctx)
|
|
131
|
-
})
|
|
132
|
-
expect(captured).toBe('inside')
|
|
133
|
-
// After withContext, should be back to default
|
|
134
|
-
expect(useContext(ctx)).toBe('default')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
test('restores stack on normal completion', () => {
|
|
138
|
-
const ctx = createContext('default')
|
|
139
|
-
withContext(ctx, 'temp', () => {
|
|
140
|
-
expect(useContext(ctx)).toBe('temp')
|
|
141
|
-
})
|
|
142
|
-
expect(useContext(ctx)).toBe('default')
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('restores stack even when callback throws', () => {
|
|
146
|
-
const ctx = createContext('safe')
|
|
147
|
-
try {
|
|
148
|
-
withContext(ctx, 'dangerous', () => {
|
|
149
|
-
expect(useContext(ctx)).toBe('dangerous')
|
|
150
|
-
throw new Error('boom')
|
|
151
|
-
})
|
|
152
|
-
} catch {
|
|
153
|
-
// expected
|
|
154
|
-
}
|
|
155
|
-
expect(useContext(ctx)).toBe('safe')
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
test('nested withContext calls', () => {
|
|
159
|
-
const ctx = createContext(0)
|
|
160
|
-
withContext(ctx, 1, () => {
|
|
161
|
-
expect(useContext(ctx)).toBe(1)
|
|
162
|
-
withContext(ctx, 2, () => {
|
|
163
|
-
expect(useContext(ctx)).toBe(2)
|
|
164
|
-
withContext(ctx, 3, () => {
|
|
165
|
-
expect(useContext(ctx)).toBe(3)
|
|
166
|
-
})
|
|
167
|
-
expect(useContext(ctx)).toBe(2)
|
|
168
|
-
})
|
|
169
|
-
expect(useContext(ctx)).toBe(1)
|
|
170
|
-
})
|
|
171
|
-
expect(useContext(ctx)).toBe(0)
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
test('multiple contexts in nested withContext', () => {
|
|
175
|
-
const theme = createContext('light')
|
|
176
|
-
const lang = createContext('en')
|
|
177
|
-
|
|
178
|
-
withContext(theme, 'dark', () => {
|
|
179
|
-
withContext(lang, 'fr', () => {
|
|
180
|
-
expect(useContext(theme)).toBe('dark')
|
|
181
|
-
expect(useContext(lang)).toBe('fr')
|
|
182
|
-
})
|
|
183
|
-
expect(useContext(lang)).toBe('en')
|
|
184
|
-
})
|
|
185
|
-
expect(useContext(theme)).toBe('light')
|
|
186
|
-
})
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
describe('provide', () => {
|
|
190
|
-
test('pushes context and registers unmount cleanup', () => {
|
|
191
|
-
const ctx = createContext('default')
|
|
192
|
-
const { hooks } = runWithHooks(
|
|
193
|
-
(() => {
|
|
194
|
-
provide(ctx, 'provided-value')
|
|
195
|
-
expect(useContext(ctx)).toBe('provided-value')
|
|
196
|
-
return null
|
|
197
|
-
}) as ComponentFn,
|
|
198
|
-
{} as Props,
|
|
199
|
-
)
|
|
200
|
-
// Context should still be available after runWithHooks
|
|
201
|
-
expect(useContext(ctx)).toBe('provided-value')
|
|
202
|
-
// unmount hooks should include the popContext cleanup
|
|
203
|
-
expect(hooks.unmount!.length).toBeGreaterThanOrEqual(1)
|
|
204
|
-
// Running unmount cleans up
|
|
205
|
-
for (const fn of hooks.unmount!) fn()
|
|
206
|
-
expect(useContext(ctx)).toBe('default')
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('multiple provides in same component', () => {
|
|
210
|
-
const ctxA = createContext('a')
|
|
211
|
-
const ctxB = createContext('b')
|
|
212
|
-
const { hooks } = runWithHooks(
|
|
213
|
-
(() => {
|
|
214
|
-
provide(ctxA, 'A-value')
|
|
215
|
-
provide(ctxB, 'B-value')
|
|
216
|
-
return null
|
|
217
|
-
}) as ComponentFn,
|
|
218
|
-
{} as Props,
|
|
219
|
-
)
|
|
220
|
-
expect(useContext(ctxA)).toBe('A-value')
|
|
221
|
-
expect(useContext(ctxB)).toBe('B-value')
|
|
222
|
-
// Clean up
|
|
223
|
-
for (const fn of hooks.unmount!) fn()
|
|
224
|
-
expect(useContext(ctxA)).toBe('a')
|
|
225
|
-
expect(useContext(ctxB)).toBe('b')
|
|
226
|
-
})
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
describe('setContextStackProvider', () => {
|
|
230
|
-
test('overrides the stack provider', () => {
|
|
231
|
-
const customStack: Map<symbol, unknown>[] = []
|
|
232
|
-
const ctx = createContext('default')
|
|
233
|
-
|
|
234
|
-
setContextStackProvider(() => customStack)
|
|
235
|
-
|
|
236
|
-
customStack.push(new Map([[ctx.id, 'custom']]))
|
|
237
|
-
expect(useContext(ctx)).toBe('custom')
|
|
238
|
-
customStack.pop()
|
|
239
|
-
expect(useContext(ctx)).toBe('default')
|
|
240
|
-
|
|
241
|
-
// Restore default provider
|
|
242
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
243
|
-
setContextStackProvider(() => freshStack)
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
test('different providers see different stacks', () => {
|
|
247
|
-
const ctx = createContext('default')
|
|
248
|
-
const stack1: Map<symbol, unknown>[] = []
|
|
249
|
-
const stack2: Map<symbol, unknown>[] = []
|
|
250
|
-
|
|
251
|
-
setContextStackProvider(() => stack1)
|
|
252
|
-
pushContext(new Map([[ctx.id, 'stack1-value']]))
|
|
253
|
-
expect(useContext(ctx)).toBe('stack1-value')
|
|
254
|
-
|
|
255
|
-
// Switch to stack2 — should not see stack1's value
|
|
256
|
-
setContextStackProvider(() => stack2)
|
|
257
|
-
expect(useContext(ctx)).toBe('default')
|
|
258
|
-
|
|
259
|
-
// Clean up
|
|
260
|
-
setContextStackProvider(() => stack1)
|
|
261
|
-
popContext()
|
|
262
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
263
|
-
setContextStackProvider(() => freshStack)
|
|
264
|
-
})
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
// ─── captureContextStack — dedup semantics ───────────────────────────────────
|
|
268
|
-
//
|
|
269
|
-
// The capture step deduplicates: only the topmost frame per context-id is
|
|
270
|
-
// retained in the snapshot. This is a HEAP-LEAK fix: under deeply-nested
|
|
271
|
-
// reactive boundaries, each effect's setup-time snapshot used to grow with
|
|
272
|
-
// the live stack's transient duplicates (40k+ entries reported in 0.21.x;
|
|
273
|
-
// see context.ts JSDoc for the full story). Dedup collapses the captured
|
|
274
|
-
// size to ~N entries where N is the number of distinct context ids in
|
|
275
|
-
// scope (typically 2-10 in real apps).
|
|
276
|
-
//
|
|
277
|
-
// Safety property: `useContext` walks the stack in reverse and stops at
|
|
278
|
-
// the first matching frame; any shadowed frame is unreachable. The dedup
|
|
279
|
-
// preserves the topmost frame per id, so `useContext` returns the same
|
|
280
|
-
// value before and after.
|
|
281
|
-
|
|
282
|
-
describe('captureContextStack — dedup', () => {
|
|
283
|
-
const restoreStack: Map<symbol, unknown>[][] = []
|
|
284
|
-
let testStack: Map<symbol, unknown>[]
|
|
285
|
-
|
|
286
|
-
beforeEach(() => {
|
|
287
|
-
testStack = []
|
|
288
|
-
setContextStackProvider(() => testStack)
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
afterEach(() => {
|
|
292
|
-
while (restoreStack.length > 0) restoreStack.pop()
|
|
293
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
294
|
-
setContextStackProvider(() => freshStack)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
test('empty stack snapshot is empty', () => {
|
|
298
|
-
expect(captureContextStack()).toEqual([])
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
test('single frame snapshot is identical', () => {
|
|
302
|
-
const ctx = createContext('default')
|
|
303
|
-
pushContext(new Map([[ctx.id, 'A']]))
|
|
304
|
-
const snap = captureContextStack()
|
|
305
|
-
expect(snap).toHaveLength(1)
|
|
306
|
-
expect(snap[0]).toBe(testStack[0]) // same reference
|
|
307
|
-
popContext()
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
test('stack with no duplicate ids snapshots verbatim', () => {
|
|
311
|
-
const a = createContext('a-default')
|
|
312
|
-
const b = createContext('b-default')
|
|
313
|
-
const c = createContext('c-default')
|
|
314
|
-
pushContext(new Map([[a.id, 'A']]))
|
|
315
|
-
pushContext(new Map([[b.id, 'B']]))
|
|
316
|
-
pushContext(new Map([[c.id, 'C']]))
|
|
317
|
-
const snap = captureContextStack()
|
|
318
|
-
expect(snap).toHaveLength(3)
|
|
319
|
-
expect(snap.map((f) => Array.from(f.values()))).toEqual([['A'], ['B'], ['C']])
|
|
320
|
-
popContext()
|
|
321
|
-
popContext()
|
|
322
|
-
popContext()
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
test('duplicate ids collapse to topmost', () => {
|
|
326
|
-
// Same context-id pushed 3 times — typical of nested restoreContextStack
|
|
327
|
-
// windows. Only the topmost should appear in the snapshot.
|
|
328
|
-
const ctx = createContext('default')
|
|
329
|
-
pushContext(new Map([[ctx.id, 'A']]))
|
|
330
|
-
pushContext(new Map([[ctx.id, 'B']]))
|
|
331
|
-
pushContext(new Map([[ctx.id, 'C']]))
|
|
332
|
-
const snap = captureContextStack()
|
|
333
|
-
expect(snap).toHaveLength(1)
|
|
334
|
-
expect(snap[0]!.get(ctx.id)).toBe('C') // topmost wins
|
|
335
|
-
popContext()
|
|
336
|
-
popContext()
|
|
337
|
-
popContext()
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
test('mixed: deep stack with mostly duplicates collapses', () => {
|
|
341
|
-
// Simulates the bug shape: same context pushed 40 times via nested
|
|
342
|
-
// restore windows + one unique frame at the top.
|
|
343
|
-
const repeated = createContext('default')
|
|
344
|
-
const unique = createContext('default')
|
|
345
|
-
for (let i = 0; i < 40; i++) {
|
|
346
|
-
pushContext(new Map([[repeated.id, `dup-${i}`]]))
|
|
347
|
-
}
|
|
348
|
-
pushContext(new Map([[unique.id, 'unique']]))
|
|
349
|
-
expect(testStack).toHaveLength(41)
|
|
350
|
-
|
|
351
|
-
const snap = captureContextStack()
|
|
352
|
-
// Result: topmost `repeated` frame + the `unique` frame = 2 entries.
|
|
353
|
-
// Pre-fix this snapshot would have all 41 frames — the leak.
|
|
354
|
-
expect(snap).toHaveLength(2)
|
|
355
|
-
// Ordering must match push order (bottom-to-top in the array).
|
|
356
|
-
expect(snap[0]!.get(repeated.id)).toBe('dup-39')
|
|
357
|
-
expect(snap[1]!.get(unique.id)).toBe('unique')
|
|
358
|
-
|
|
359
|
-
for (let i = 0; i < 41; i++) popContext()
|
|
360
|
-
})
|
|
361
|
-
|
|
362
|
-
test('multi-key frame: kept if it provides ANY un-shadowed id', () => {
|
|
363
|
-
// Frame with two contexts; only one is shadowed by a deeper push.
|
|
364
|
-
const a = createContext('a')
|
|
365
|
-
const b = createContext('b')
|
|
366
|
-
pushContext(new Map<symbol, unknown>([[a.id, 'a1'], [b.id, 'b1']]))
|
|
367
|
-
pushContext(new Map([[a.id, 'a2']])) // shadows `a`, NOT `b`
|
|
368
|
-
|
|
369
|
-
const snap = captureContextStack()
|
|
370
|
-
// Both frames should remain: the upper provides `a`, the lower
|
|
371
|
-
// still provides un-shadowed `b`.
|
|
372
|
-
expect(snap).toHaveLength(2)
|
|
373
|
-
|
|
374
|
-
// Verify useContext semantics survive: a→a2, b→b1
|
|
375
|
-
setContextStackProvider(() => snap)
|
|
376
|
-
expect(useContext(a)).toBe('a2')
|
|
377
|
-
expect(useContext(b)).toBe('b1')
|
|
378
|
-
setContextStackProvider(() => testStack)
|
|
379
|
-
|
|
380
|
-
popContext()
|
|
381
|
-
popContext()
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
test('multi-key frame: dropped if ALL its ids are shadowed', () => {
|
|
385
|
-
const a = createContext('a')
|
|
386
|
-
const b = createContext('b')
|
|
387
|
-
pushContext(new Map<symbol, unknown>([[a.id, 'a1'], [b.id, 'b1']]))
|
|
388
|
-
pushContext(new Map<symbol, unknown>([[a.id, 'a2'], [b.id, 'b2']]))
|
|
389
|
-
|
|
390
|
-
const snap = captureContextStack()
|
|
391
|
-
expect(snap).toHaveLength(1)
|
|
392
|
-
expect(snap[0]!.get(a.id)).toBe('a2')
|
|
393
|
-
expect(snap[0]!.get(b.id)).toBe('b2')
|
|
394
|
-
|
|
395
|
-
popContext()
|
|
396
|
-
popContext()
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
test('useContext returns same value pre/post dedup for arbitrary read patterns', () => {
|
|
400
|
-
// Cross-check: build a complex stack, capture, then verify useContext
|
|
401
|
-
// returns the same value when reading from the original stack vs the
|
|
402
|
-
// deduped snapshot. This is the load-bearing semantic-equivalence
|
|
403
|
-
// assertion for the safety argument.
|
|
404
|
-
const a = createContext('a-default')
|
|
405
|
-
const b = createContext('b-default')
|
|
406
|
-
const c = createContext('c-default')
|
|
407
|
-
pushContext(new Map([[a.id, 'a1']]))
|
|
408
|
-
pushContext(new Map<symbol, unknown>([[a.id, 'a2'], [b.id, 'b1']]))
|
|
409
|
-
pushContext(new Map([[c.id, 'c1']]))
|
|
410
|
-
pushContext(new Map([[a.id, 'a3']]))
|
|
411
|
-
pushContext(new Map([[b.id, 'b2']]))
|
|
412
|
-
|
|
413
|
-
// Read against original stack
|
|
414
|
-
const beforeA = useContext(a)
|
|
415
|
-
const beforeB = useContext(b)
|
|
416
|
-
const beforeC = useContext(c)
|
|
417
|
-
|
|
418
|
-
// Capture (dedup happens) and read against the snapshot
|
|
419
|
-
const snap = captureContextStack()
|
|
420
|
-
setContextStackProvider(() => snap)
|
|
421
|
-
const afterA = useContext(a)
|
|
422
|
-
const afterB = useContext(b)
|
|
423
|
-
const afterC = useContext(c)
|
|
424
|
-
|
|
425
|
-
expect(afterA).toBe(beforeA) // 'a3' from the topmost frame
|
|
426
|
-
expect(afterB).toBe(beforeB) // 'b2' from the topmost frame
|
|
427
|
-
expect(afterC).toBe(beforeC) // 'c1' (still the only c-provider)
|
|
428
|
-
|
|
429
|
-
// Clean up
|
|
430
|
-
setContextStackProvider(() => testStack)
|
|
431
|
-
for (let i = 0; i < 5; i++) popContext()
|
|
432
|
-
})
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
// ─── restoreContextStack — works against deduped snapshots ───────────────────
|
|
436
|
-
|
|
437
|
-
describe('restoreContextStack — with deduped snapshots', () => {
|
|
438
|
-
let testStack: Map<symbol, unknown>[]
|
|
439
|
-
|
|
440
|
-
beforeEach(() => {
|
|
441
|
-
testStack = []
|
|
442
|
-
setContextStackProvider(() => testStack)
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
afterEach(() => {
|
|
446
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
447
|
-
setContextStackProvider(() => freshStack)
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
test('restores deduped snapshot — fn() sees correct context, stack cleans up', () => {
|
|
451
|
-
const ctx = createContext('default')
|
|
452
|
-
pushContext(new Map([[ctx.id, 'A']]))
|
|
453
|
-
pushContext(new Map([[ctx.id, 'B']]))
|
|
454
|
-
pushContext(new Map([[ctx.id, 'C']]))
|
|
455
|
-
|
|
456
|
-
const snap = captureContextStack()
|
|
457
|
-
expect(snap).toHaveLength(1) // dedup collapsed to topmost
|
|
458
|
-
|
|
459
|
-
// Now empty the stack to simulate post-mount state
|
|
460
|
-
popContext()
|
|
461
|
-
popContext()
|
|
462
|
-
popContext()
|
|
463
|
-
expect(testStack).toHaveLength(0)
|
|
464
|
-
|
|
465
|
-
// Restore the deduped snapshot
|
|
466
|
-
const observed = restoreContextStack(snap, () => {
|
|
467
|
-
// Inside fn(): stack has the deduped frame
|
|
468
|
-
expect(testStack).toHaveLength(1)
|
|
469
|
-
return useContext(ctx)
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
// fn() saw the topmost-frame value, NOT 'default' — semantic equivalence
|
|
473
|
-
expect(observed).toBe('C')
|
|
474
|
-
// After restore, the snapshot's frames are removed by reference identity
|
|
475
|
-
expect(testStack).toHaveLength(0)
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
test('restoring 40-duplicate stack only pushes/pops 1 frame post-dedup', () => {
|
|
479
|
-
// This is the bug-shape regression test. Pre-dedup, this snapshot was
|
|
480
|
-
// 40 entries; restore pushed 40 then removed 40. Post-dedup, both
|
|
481
|
-
// operations move 1 frame.
|
|
482
|
-
const ctx = createContext('default')
|
|
483
|
-
for (let i = 0; i < 40; i++) {
|
|
484
|
-
pushContext(new Map([[ctx.id, `dup-${i}`]]))
|
|
485
|
-
}
|
|
486
|
-
const snap = captureContextStack()
|
|
487
|
-
expect(snap).toHaveLength(1)
|
|
488
|
-
|
|
489
|
-
// Empty the live stack so the restore is observable in isolation.
|
|
490
|
-
while (testStack.length > 0) popContext()
|
|
491
|
-
|
|
492
|
-
let observedLenInside = -1
|
|
493
|
-
restoreContextStack(snap, () => {
|
|
494
|
-
observedLenInside = testStack.length
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
// 1 push during fn, 1 splice after = stack stays balanced.
|
|
498
|
-
expect(observedLenInside).toBe(1)
|
|
499
|
-
expect(testStack).toHaveLength(0)
|
|
500
|
-
})
|
|
501
|
-
})
|
|
502
|
-
|
|
503
|
-
// ─── Leak audit: snapshot allocations stay bounded under deep stacks ─────────
|
|
504
|
-
//
|
|
505
|
-
// This is the regression lock for the heap-snapshot finding that motivated
|
|
506
|
-
// the dedup. Reported in 0.21.x: 1.22 MB / 321k-entry arrays retained by
|
|
507
|
-
// effect closures under deeply-nested reactive boundaries. The 0.23.0
|
|
508
|
-
// restoreContextStack fix cleaned the live stack but residual snapshot
|
|
509
|
-
// amplification persisted (~3 MB / 20×40k-entry arrays). This dedup
|
|
510
|
-
// closes that. The test below makes the bug-shape impossible to
|
|
511
|
-
// re-introduce silently: it builds the deep-stack scenario, captures
|
|
512
|
-
// N snapshots that previously would each have held the stack-depth, and
|
|
513
|
-
// asserts the TOTAL frame count across all snapshots scales with the
|
|
514
|
-
// number of DISTINCT context ids in scope, NOT with the stack depth.
|
|
515
|
-
|
|
516
|
-
describe('captureContextStack — leak audit (regression lock)', () => {
|
|
517
|
-
let testStack: Map<symbol, unknown>[]
|
|
518
|
-
|
|
519
|
-
beforeEach(() => {
|
|
520
|
-
testStack = []
|
|
521
|
-
setContextStackProvider(() => testStack)
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
afterEach(() => {
|
|
525
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
526
|
-
setContextStackProvider(() => freshStack)
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
test('1000 snapshots of a deep duplicate-heavy stack retain bounded total frames', () => {
|
|
530
|
-
// Build a stack of 100 frames, all pushing the same context (simulates
|
|
531
|
-
// nested restoreContextStack windows). Then capture 1000 snapshots —
|
|
532
|
-
// one per effect setup, as happens in a large component tree.
|
|
533
|
-
const ctx = createContext('default')
|
|
534
|
-
for (let i = 0; i < 100; i++) {
|
|
535
|
-
pushContext(new Map([[ctx.id, `dup-${i}`]]))
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const snapshots: ContextSnapshot[] = []
|
|
539
|
-
for (let i = 0; i < 1000; i++) {
|
|
540
|
-
snapshots.push(captureContextStack())
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Pre-dedup: 1000 snapshots × 100 frames = 100,000 frame references.
|
|
544
|
-
// Post-dedup: 1000 snapshots × 1 frame (topmost) = 1,000 frame references.
|
|
545
|
-
// The assertion bounds total retention at the dedup-correct ceiling.
|
|
546
|
-
const totalFrames = snapshots.reduce((sum, s) => sum + s.length, 0)
|
|
547
|
-
expect(totalFrames).toBe(1000) // 1000 snapshots × 1 unique id
|
|
548
|
-
|
|
549
|
-
// Clean up
|
|
550
|
-
for (let i = 0; i < 100; i++) popContext()
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
test('mixed deep stack: total frames bounded by distinct id count, not depth', () => {
|
|
554
|
-
// 50 unique contexts pushed into a stack of 500 frames (10 duplicates
|
|
555
|
-
// per context). Capture 100 snapshots.
|
|
556
|
-
const ctxs = Array.from({ length: 50 }, () => createContext('default'))
|
|
557
|
-
for (let depth = 0; depth < 10; depth++) {
|
|
558
|
-
for (const ctx of ctxs) {
|
|
559
|
-
pushContext(new Map([[ctx.id, `d${depth}`]]))
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
expect(testStack).toHaveLength(500)
|
|
563
|
-
|
|
564
|
-
const snapshots: ContextSnapshot[] = []
|
|
565
|
-
for (let i = 0; i < 100; i++) {
|
|
566
|
-
snapshots.push(captureContextStack())
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Pre-dedup: 100 × 500 = 50,000 frame references.
|
|
570
|
-
// Post-dedup: 100 × 50 (topmost per distinct id) = 5,000 frame
|
|
571
|
-
// references. 10× reduction matches the empirical bug-shape.
|
|
572
|
-
const totalFrames = snapshots.reduce((sum, s) => sum + s.length, 0)
|
|
573
|
-
expect(totalFrames).toBe(100 * 50)
|
|
574
|
-
|
|
575
|
-
// Clean up
|
|
576
|
-
for (let i = 0; i < 500; i++) popContext()
|
|
577
|
-
})
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
// ─── getContextStackLength ──────────────────────────────────────────────────
|
|
581
|
-
|
|
582
|
-
describe('getContextStackLength', () => {
|
|
583
|
-
let testStack: Map<symbol, unknown>[]
|
|
584
|
-
|
|
585
|
-
beforeEach(() => {
|
|
586
|
-
testStack = []
|
|
587
|
-
setContextStackProvider(() => testStack)
|
|
588
|
-
})
|
|
589
|
-
|
|
590
|
-
afterEach(() => {
|
|
591
|
-
const freshStack: Map<symbol, unknown>[] = []
|
|
592
|
-
setContextStackProvider(() => freshStack)
|
|
593
|
-
})
|
|
594
|
-
|
|
595
|
-
test('returns the LIVE stack length, not the deduped snapshot length', () => {
|
|
596
|
-
// This is the load-bearing distinction: SSR cleanup uses
|
|
597
|
-
// `getContextStackLength()` as a position marker, and it must reflect
|
|
598
|
-
// the live (un-deduped) stack length so subsequent `popContext` calls
|
|
599
|
-
// pop the right number of frames.
|
|
600
|
-
const ctx = createContext('default')
|
|
601
|
-
pushContext(new Map([[ctx.id, 'A']]))
|
|
602
|
-
pushContext(new Map([[ctx.id, 'B']]))
|
|
603
|
-
pushContext(new Map([[ctx.id, 'C']]))
|
|
604
|
-
|
|
605
|
-
expect(getContextStackLength()).toBe(3) // live length
|
|
606
|
-
expect(captureContextStack()).toHaveLength(1) // deduped snapshot length
|
|
607
|
-
|
|
608
|
-
popContext()
|
|
609
|
-
popContext()
|
|
610
|
-
popContext()
|
|
611
|
-
})
|
|
612
|
-
|
|
613
|
-
test('zero on empty stack', () => {
|
|
614
|
-
expect(getContextStackLength()).toBe(0)
|
|
615
|
-
})
|
|
616
|
-
|
|
617
|
-
test('matches stack array length after push/pop cycles', () => {
|
|
618
|
-
const ctx = createContext('default')
|
|
619
|
-
expect(getContextStackLength()).toBe(0)
|
|
620
|
-
pushContext(new Map([[ctx.id, 'A']]))
|
|
621
|
-
expect(getContextStackLength()).toBe(1)
|
|
622
|
-
pushContext(new Map([[ctx.id, 'B']]))
|
|
623
|
-
expect(getContextStackLength()).toBe(2)
|
|
624
|
-
popContext()
|
|
625
|
-
expect(getContextStackLength()).toBe(1)
|
|
626
|
-
popContext()
|
|
627
|
-
expect(getContextStackLength()).toBe(0)
|
|
628
|
-
})
|
|
629
|
-
})
|