@pyreon/core 0.24.5 → 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
package/src/context.ts
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provide / inject — like React context or Vue provide/inject.
|
|
3
|
-
*
|
|
4
|
-
* Values flow down the component tree without prop-drilling.
|
|
5
|
-
* The renderer maintains the context stack as it walks the VNode tree.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { setSnapshotCapture } from '@pyreon/reactivity'
|
|
9
|
-
import { onUnmount } from './lifecycle'
|
|
10
|
-
|
|
11
|
-
export interface Context<T> {
|
|
12
|
-
readonly id: symbol
|
|
13
|
-
readonly defaultValue: T
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Branded marker for reactive contexts — distinguishes from regular Context at type level. */
|
|
17
|
-
declare const REACTIVE_BRAND: unique symbol
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A context whose value is a reactive accessor `() => T`.
|
|
21
|
-
*
|
|
22
|
-
* When you `useContext(reactiveCtx)`, TypeScript returns `() => T` —
|
|
23
|
-
* you MUST call the accessor to read the value. This prevents the
|
|
24
|
-
* destructuring trap that breaks reactivity with getter-based objects.
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* const ModeCtx = createReactiveContext<'light' | 'dark'>('light')
|
|
28
|
-
* // Provider: provide(ModeCtx, () => modeSignal())
|
|
29
|
-
* // Consumer: const getMode = useContext(ModeCtx); getMode() // 'light'
|
|
30
|
-
*/
|
|
31
|
-
export interface ReactiveContext<T> extends Context<() => T> {
|
|
32
|
-
readonly [REACTIVE_BRAND]: T
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createContext<T>(defaultValue: T): Context<T> {
|
|
36
|
-
return { id: Symbol('PyreonContext'), defaultValue }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Create a reactive context. Consumers get `() => T` and must call it.
|
|
41
|
-
* This is the safe pattern for values that change over time (mode, locale, etc.).
|
|
42
|
-
*/
|
|
43
|
-
export function createReactiveContext<T>(defaultValue: T): ReactiveContext<T> {
|
|
44
|
-
return createContext<() => T>(() => defaultValue) as ReactiveContext<T>
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ─── Runtime context stack (managed by the renderer) ─────────────────────────
|
|
48
|
-
|
|
49
|
-
// Default stack — used for CSR and single-threaded SSR.
|
|
50
|
-
// On Node.js with concurrent requests, @pyreon/runtime-server replaces this with
|
|
51
|
-
// an AsyncLocalStorage-backed provider via setContextStackProvider().
|
|
52
|
-
const _defaultStack: Map<symbol, unknown>[] = []
|
|
53
|
-
let _stackProvider: () => Map<symbol, unknown>[] = () => _defaultStack
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Override the context stack provider. Called by @pyreon/runtime-server to
|
|
57
|
-
* inject an AsyncLocalStorage-backed stack that isolates concurrent SSR requests.
|
|
58
|
-
* Has no effect in the browser (CSR always uses the default module-level stack).
|
|
59
|
-
*/
|
|
60
|
-
export function setContextStackProvider(fn: () => Map<symbol, unknown>[]): void {
|
|
61
|
-
_stackProvider = fn
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function getStack(): Map<symbol, unknown>[] {
|
|
65
|
-
return _stackProvider()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
69
|
-
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
70
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
71
|
-
|
|
72
|
-
export function pushContext(values: Map<symbol, unknown>) {
|
|
73
|
-
getStack().push(values)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Pop the LAST frame from the context stack.
|
|
78
|
-
*
|
|
79
|
-
* NOTE: position-based pop. Safe ONLY when the caller can guarantee that the
|
|
80
|
-
* top of the stack is the frame they want to remove (the strict LIFO contract).
|
|
81
|
-
* The `provide()` helper does NOT use this — it uses identity-based removal
|
|
82
|
-
* via `removeContextFrame` because reactive boundaries can push snapshot
|
|
83
|
-
* frames between a component's `provide(ctx, value)` and its eventual
|
|
84
|
-
* unmount, making the top-of-stack unsafe to assume.
|
|
85
|
-
*/
|
|
86
|
-
export function popContext() {
|
|
87
|
-
const stack = getStack()
|
|
88
|
-
if (stack.length === 0) return
|
|
89
|
-
stack.pop()
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Read the current live stack length WITHOUT allocating a snapshot.
|
|
94
|
-
*
|
|
95
|
-
* SSR cleanup uses this as a position marker: capture the live length
|
|
96
|
-
* before a component renders, pop the live stack back to that length
|
|
97
|
-
* after. Previously these sites called `captureContextStack().length`,
|
|
98
|
-
* which allocated a full snapshot array (potentially 40k+ entries
|
|
99
|
-
* under deeply-nested reactive boundaries — the same allocation the
|
|
100
|
-
* `captureContextStack` dedup work is designed to shrink) just to
|
|
101
|
-
* read its length. This helper avoids the allocation entirely AND
|
|
102
|
-
* decouples SSR cleanup from `captureContextStack`'s snapshot shape,
|
|
103
|
-
* so dedup at capture time can never silently break SSR length
|
|
104
|
-
* bookkeeping.
|
|
105
|
-
*/
|
|
106
|
-
export function getContextStackLength(): number {
|
|
107
|
-
return getStack().length
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Remove a SPECIFIC frame from the context stack by reference identity.
|
|
112
|
-
*
|
|
113
|
-
* Internal — used by `provide()` and `withContext()` to safely clean up
|
|
114
|
-
* their pushed frame on unmount even when other frames have been pushed
|
|
115
|
-
* between push and pop (e.g. a reactive boundary's `restoreContextStack`
|
|
116
|
-
* pushing snapshot frames during the descendant's lifecycle). The
|
|
117
|
-
* symmetric position-based `popContext()` would pop the wrong frame in
|
|
118
|
-
* that case and orphan the descendant's provider frame on the live stack
|
|
119
|
-
* — the root cause of the 321k-entry context-stack leak under repeated
|
|
120
|
-
* reactive remounts.
|
|
121
|
-
*
|
|
122
|
-
* Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
|
|
123
|
-
* with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
|
|
124
|
-
* by two unmounts removes them in reverse order.
|
|
125
|
-
*/
|
|
126
|
-
export function removeContextFrame(frame: Map<symbol, unknown>): void {
|
|
127
|
-
const stack = getStack()
|
|
128
|
-
const idx = stack.lastIndexOf(frame)
|
|
129
|
-
if (idx !== -1) stack.splice(idx, 1)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Read the nearest provided value for a context.
|
|
134
|
-
* Falls back to `context.defaultValue` if none found.
|
|
135
|
-
*
|
|
136
|
-
* For ReactiveContext<T>, returns `() => T` — you MUST call the accessor.
|
|
137
|
-
* For regular Context<T>, returns `T` directly.
|
|
138
|
-
*/
|
|
139
|
-
export function useContext<T>(context: ReactiveContext<T>): () => T
|
|
140
|
-
export function useContext<T>(context: Context<T>): T
|
|
141
|
-
export function useContext<T>(context: Context<T>): T {
|
|
142
|
-
const stack = getStack()
|
|
143
|
-
for (let i = stack.length - 1; i >= 0; i--) {
|
|
144
|
-
const frame = stack[i]
|
|
145
|
-
if (frame?.has(context.id)) {
|
|
146
|
-
return frame.get(context.id) as T
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return context.defaultValue
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Provide a context value for the current component's subtree.
|
|
154
|
-
* Must be called during component setup (like onMount/onUnmount).
|
|
155
|
-
* Automatically cleaned up when the component unmounts.
|
|
156
|
-
*
|
|
157
|
-
* @example
|
|
158
|
-
* const ThemeProvider = ({ children }: { children: VNodeChild }) => {
|
|
159
|
-
* provide(ThemeContext, { color: "blue" })
|
|
160
|
-
* return children
|
|
161
|
-
* }
|
|
162
|
-
*/
|
|
163
|
-
export function provide<T>(context: Context<T>, value: T): void {
|
|
164
|
-
const frame = new Map<symbol, unknown>([[context.id, value]])
|
|
165
|
-
pushContext(frame)
|
|
166
|
-
// Identity-based removal — the top of the stack is NOT guaranteed to be
|
|
167
|
-
// this frame at unmount time. Reactive boundaries (`mountReactive`'s
|
|
168
|
-
// effect snapshot-restore + the inner `restoreContextStack` call) push
|
|
169
|
-
// additional snapshot frames during a descendant's lifecycle. A
|
|
170
|
-
// position-based `popContext()` would pop the snapshot frame instead
|
|
171
|
-
// of this provider's frame and orphan the provider on the live stack.
|
|
172
|
-
// See `.claude/rules/anti-patterns.md` "Context-stack frame identity"
|
|
173
|
-
// for the full bug class.
|
|
174
|
-
onUnmount(() => removeContextFrame(frame))
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Provide a value for `context` during `fn()`.
|
|
179
|
-
* Used by the renderer when it encounters a `<Provider>` component.
|
|
180
|
-
*/
|
|
181
|
-
export function withContext<T>(context: Context<T>, value: T, fn: () => void) {
|
|
182
|
-
const frame = new Map<symbol, unknown>([[context.id, value]])
|
|
183
|
-
pushContext(frame)
|
|
184
|
-
try {
|
|
185
|
-
fn()
|
|
186
|
-
} finally {
|
|
187
|
-
// Same identity-based-removal rationale as `provide()` — `fn()` may
|
|
188
|
-
// synchronously trigger a `mountReactive` re-run whose snapshot-restore
|
|
189
|
-
// window leaves the top-of-stack pointing at a snapshot push, not our
|
|
190
|
-
// frame.
|
|
191
|
-
removeContextFrame(frame)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ─── Context snapshot for deferred mounting ─────────────────────────────────
|
|
196
|
-
|
|
197
|
-
export type ContextSnapshot = Map<symbol, unknown>[]
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Capture a snapshot of the current context stack, **deduplicated** so
|
|
201
|
-
* only the topmost frame for each context-id is retained.
|
|
202
|
-
*
|
|
203
|
-
* Used by `mountReactive` to preserve the context that was active when a
|
|
204
|
-
* reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
|
|
205
|
-
* later mounts new children inside an effect, the snapshot is restored so
|
|
206
|
-
* those children can see ancestor providers via `useContext()`.
|
|
207
|
-
*
|
|
208
|
-
* **Why dedup is semantically equivalent to a full snapshot:**
|
|
209
|
-
* `useContext()` walks the stack in reverse and returns the first frame
|
|
210
|
-
* matching the requested context-id (`for (let i = stack.length - 1; i >= 0; i--)`
|
|
211
|
-
* — see implementation below in this file). Any frame deeper in the
|
|
212
|
-
* stack that ALSO provides the same id is unreachable by definition —
|
|
213
|
-
* the reverse walk stops at the first match. Those shadowed frames are
|
|
214
|
-
* dead weight in the snapshot: they carry no observable value, they
|
|
215
|
-
* cost memory, and they can NEVER affect program behavior.
|
|
216
|
-
*
|
|
217
|
-
* The dedup walks frames from top to bottom keeping a `seen` set of
|
|
218
|
-
* already-resolved context ids. A frame is kept iff at least one of
|
|
219
|
-
* its keys is NOT in `seen` (i.e. it's the topmost provider for at
|
|
220
|
-
* least one id). All of a frame's keys are added to `seen` regardless
|
|
221
|
-
* of whether the frame is kept — `seen` represents "ids that are
|
|
222
|
-
* already provided by a more-recent frame".
|
|
223
|
-
*
|
|
224
|
-
* **Why this is safe for `restoreContextStack`:**
|
|
225
|
-
* `restoreContextStack` pushes the snapshot's frames onto the live
|
|
226
|
-
* stack, runs `fn()`, then removes those frames by **reference
|
|
227
|
-
* identity** (`stack.lastIndexOf(frame)`) — NOT by position or count
|
|
228
|
-
* of the snapshot. A deduped snapshot pushes fewer frames; the same
|
|
229
|
-
* reference-identity cleanup removes exactly those frames. No
|
|
230
|
-
* bookkeeping invariant breaks.
|
|
231
|
-
*
|
|
232
|
-
* **Why this is safe for the live stack length invariant:**
|
|
233
|
-
* SSR cleanup uses `getContextStackLength()` (a sibling helper) for
|
|
234
|
-
* position-marker bookkeeping. That helper reads the LIVE stack
|
|
235
|
-
* length, NOT the snapshot length, so dedup at capture time has zero
|
|
236
|
-
* effect on SSR cleanup behavior.
|
|
237
|
-
*
|
|
238
|
-
* **Why this is needed:**
|
|
239
|
-
* Under deeply-nested reactive boundaries (a `<Show>` inside a `<For>`
|
|
240
|
-
* inside a `<Suspense>`, each effect capturing its own snapshot at
|
|
241
|
-
* setup time), the live stack temporarily holds the same context-id
|
|
242
|
-
* pushed multiple times during nested `restoreContextStack` windows.
|
|
243
|
-
* The pre-dedup `[...getStack()]` snapshot baked those duplicates in
|
|
244
|
-
* permanently — each effect's closure retained an O(stack-depth)
|
|
245
|
-
* array for its lifetime. Reported heap snapshots from 0.21.x showed
|
|
246
|
-
* 1.22 MB / 321k-entry arrays from this pattern. The 0.23.0
|
|
247
|
-
* restoreContextStack reference-identity fix cleaned the LIVE stack
|
|
248
|
-
* but left the residual snapshot-amplification — observable as 20
|
|
249
|
-
* arrays at 157 KB each (40k entries) retained by effect closures.
|
|
250
|
-
* This dedup collapses each captured snapshot to ~N entries, where
|
|
251
|
-
* N is the number of DISTINCT context ids in scope (typically 2-10
|
|
252
|
-
* in real apps).
|
|
253
|
-
*/
|
|
254
|
-
export function captureContextStack(): ContextSnapshot {
|
|
255
|
-
const stack = getStack()
|
|
256
|
-
// Fast path: empty stack or single frame is the common case for
|
|
257
|
-
// top-level mounts and zero-context apps. Skip the dedup machinery.
|
|
258
|
-
if (stack.length <= 1) return stack.slice()
|
|
259
|
-
|
|
260
|
-
// Walk top-to-bottom, keeping the topmost frame for each context-id.
|
|
261
|
-
// Each frame is a Map<symbol, unknown>; `seen` tracks ids already
|
|
262
|
-
// provided by a more-recent frame.
|
|
263
|
-
const seen = new Set<symbol>()
|
|
264
|
-
const reversed: Map<symbol, unknown>[] = []
|
|
265
|
-
for (let i = stack.length - 1; i >= 0; i--) {
|
|
266
|
-
const frame = stack[i]
|
|
267
|
-
if (!frame) continue
|
|
268
|
-
// A frame is unique if it provides at least one not-yet-seen id.
|
|
269
|
-
// Iterate ALL keys to accumulate them into `seen` (so deeper
|
|
270
|
-
// frames sharing any one of them are correctly shadowed even if
|
|
271
|
-
// they also have other unique keys).
|
|
272
|
-
let unique = false
|
|
273
|
-
for (const id of frame.keys()) {
|
|
274
|
-
if (!seen.has(id)) {
|
|
275
|
-
seen.add(id)
|
|
276
|
-
unique = true
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (unique) reversed.push(frame)
|
|
280
|
-
}
|
|
281
|
-
// We walked top-to-bottom; the result is in reverse stack order.
|
|
282
|
-
// Reverse back so the snapshot is in bottom-to-top order, matching
|
|
283
|
-
// the order `restoreContextStack` pushes them.
|
|
284
|
-
reversed.reverse()
|
|
285
|
-
return reversed
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Execute `fn()` with a previously captured context stack active.
|
|
290
|
-
*
|
|
291
|
-
* After `fn()` returns, removes ONLY the snapshot frames this call pushed
|
|
292
|
-
* — anything `fn()` itself pushed (typically provider frames from
|
|
293
|
-
* `provide()` calls during component mount) stays on the stack so
|
|
294
|
-
* subsequent reactive re-runs (e.g. `_bind` text bindings,
|
|
295
|
-
* `renderEffect` callbacks) can still find ancestor providers via
|
|
296
|
-
* `useContext`. Pre-fix this method was `stack.length = savedLength`,
|
|
297
|
-
* which destructively truncated provider frames pushed during mount —
|
|
298
|
-
* silently breaking `useMode()` / `useTheme()` / `useRouter()` etc. on
|
|
299
|
-
* every signal-driven update under a `mountReactive` boundary.
|
|
300
|
-
*/
|
|
301
|
-
export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T {
|
|
302
|
-
const stack = getStack()
|
|
303
|
-
|
|
304
|
-
// Push captured snapshot frames at the END of the current stack.
|
|
305
|
-
for (const frame of snapshot) {
|
|
306
|
-
stack.push(frame)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
return fn()
|
|
311
|
-
} finally {
|
|
312
|
-
// Remove our pushed snapshot frames by REFERENCE IDENTITY (not by
|
|
313
|
-
// position). `fn()` may legitimately remove frames at indices BEFORE
|
|
314
|
-
// our push window — most commonly via `provide()` registering
|
|
315
|
-
// `onUnmount(removeContextFrame(frame))` and a descendant unmount
|
|
316
|
-
// firing inside this restore window. A position-based `splice` would
|
|
317
|
-
// either pull the wrong frames or no-op when the live stack has
|
|
318
|
-
// shrunk below the original `insertIndex + snapshot.length` —
|
|
319
|
-
// orphaning the snapshot pushes on the live stack and producing the
|
|
320
|
-
// 321k-frame leak reported under repeated reactive remounts.
|
|
321
|
-
//
|
|
322
|
-
// Iterate in reverse so multi-occurrence frames (the same Map ref
|
|
323
|
-
// pushed by multiple nested restores) are removed in LIFO push order.
|
|
324
|
-
// `lastIndexOf` is O(N); N is small in practice (single-digit nesting),
|
|
325
|
-
// and the alternative `findLastIndex(f => f === frame)` is the same
|
|
326
|
-
// cost.
|
|
327
|
-
for (let i = snapshot.length - 1; i >= 0; i--) {
|
|
328
|
-
const frame = snapshot[i]
|
|
329
|
-
if (!frame) continue
|
|
330
|
-
const idx = stack.lastIndexOf(frame)
|
|
331
|
-
if (idx !== -1) stack.splice(idx, 1)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ─── Reactivity-layer DI: install context capture/restore for effects ────────
|
|
337
|
-
//
|
|
338
|
-
// `_bind` / `renderEffect` / `effect` (in `@pyreon/reactivity`) capture this
|
|
339
|
-
// snapshot at setup and restore it on every subsequent re-run. Without this,
|
|
340
|
-
// signal-driven re-runs after the synchronous mount see whatever the GLOBAL
|
|
341
|
-
// context stack looks like at that moment — which may be missing provider
|
|
342
|
-
// frames for any number of reasons (sibling subtree mounts/unmounts mutating
|
|
343
|
-
// the stack, async re-render cycles, etc.). Defense-in-depth alongside the
|
|
344
|
-
// `restoreContextStack` splice fix above.
|
|
345
|
-
setSnapshotCapture({
|
|
346
|
-
capture: () => captureContextStack(),
|
|
347
|
-
restore: <T>(snap: unknown, fn: () => T): T =>
|
|
348
|
-
restoreContextStack(snap as ContextSnapshot, fn),
|
|
349
|
-
})
|
package/src/defer.ts
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { effect, signal } from '@pyreon/reactivity'
|
|
2
|
-
import { Fragment, h } from './h'
|
|
3
|
-
import { onMount } from './lifecycle'
|
|
4
|
-
import { createRef } from './ref'
|
|
5
|
-
import type { ComponentFn, Props, VNode, VNodeChild, VNodeChildAccessor } from './types'
|
|
6
|
-
|
|
7
|
-
// Dev-mode gate (bundler-agnostic, see pyreon/no-process-dev-gate).
|
|
8
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Module shape `<Defer>` accepts from `chunk()`. Mirrors `lazy()`'s
|
|
12
|
-
* contract — either an ES module with `default` export, OR a raw
|
|
13
|
-
* `ComponentFn` returned directly (rare; covers re-export patterns).
|
|
14
|
-
*/
|
|
15
|
-
type ChunkResult<P extends Props> = { default: ComponentFn<P> } | ComponentFn<P>
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Trigger discriminant. Exactly ONE shape is provided:
|
|
19
|
-
* - `when={() => signal()}` — load when the accessor becomes truthy
|
|
20
|
-
* - `on="visible"` — load when the wrapper enters the viewport
|
|
21
|
-
* - `on="idle"` — load during browser idle time
|
|
22
|
-
*/
|
|
23
|
-
type DeferTrigger = { when: () => boolean } | { on: 'visible' | 'idle' }
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Set up the `on="idle"` trigger. Returns a teardown function the
|
|
27
|
-
* caller must invoke on unmount. Browser-API access is gated by
|
|
28
|
-
* `typeof` checks so SSR / jsdom environments fall back to a
|
|
29
|
-
* `setTimeout(1)` shim. Extracted as a standalone helper so it's
|
|
30
|
-
* directly testable without going through `onMount` (core tests
|
|
31
|
-
* don't run in happy-dom; runtime-dom is where the lifecycle hooks
|
|
32
|
-
* live).
|
|
33
|
-
*
|
|
34
|
-
* @internal Exported for tests; not part of the stable public API.
|
|
35
|
-
*/
|
|
36
|
-
export function _setupIdleTrigger(startLoad: () => void): () => void {
|
|
37
|
-
const ric = (
|
|
38
|
-
globalThis as { requestIdleCallback?: (cb: () => void) => number }
|
|
39
|
-
).requestIdleCallback
|
|
40
|
-
const cic = (
|
|
41
|
-
globalThis as { cancelIdleCallback?: (id: number) => void }
|
|
42
|
-
).cancelIdleCallback
|
|
43
|
-
if (typeof ric === 'function') {
|
|
44
|
-
const id = ric(startLoad)
|
|
45
|
-
return () => cic?.(id)
|
|
46
|
-
}
|
|
47
|
-
const t = setTimeout(startLoad, 1)
|
|
48
|
-
return () => clearTimeout(t)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Set up the `on="visible"` trigger. Observes `el` via an
|
|
53
|
-
* `IntersectionObserver` and fires `startLoad` once on the first
|
|
54
|
-
* intersection. If `IntersectionObserver` is unavailable (jsdom)
|
|
55
|
-
* or `el` is null (SSR), falls back to loading immediately.
|
|
56
|
-
*
|
|
57
|
-
* Returns a teardown function — call to disconnect the observer.
|
|
58
|
-
*
|
|
59
|
-
* @internal Exported for tests; not part of the stable public API.
|
|
60
|
-
*/
|
|
61
|
-
export function _setupVisibleTrigger(
|
|
62
|
-
el: HTMLElement | null,
|
|
63
|
-
startLoad: () => void,
|
|
64
|
-
rootMargin: string,
|
|
65
|
-
): () => void {
|
|
66
|
-
if (!el || typeof IntersectionObserver === 'undefined') {
|
|
67
|
-
// Observer unavailable or no DOM target — load eagerly so the
|
|
68
|
-
// user still sees the component in environments where the
|
|
69
|
-
// viewport-detection mechanism can't run.
|
|
70
|
-
startLoad()
|
|
71
|
-
return () => {}
|
|
72
|
-
}
|
|
73
|
-
const obs = new IntersectionObserver(
|
|
74
|
-
(entries) => {
|
|
75
|
-
if (entries.some((e) => e.isIntersecting)) {
|
|
76
|
-
startLoad()
|
|
77
|
-
obs.disconnect()
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
{ rootMargin },
|
|
81
|
-
)
|
|
82
|
-
obs.observe(el)
|
|
83
|
-
return () => obs.disconnect()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export type DeferProps<P extends Props> = DeferTrigger & {
|
|
87
|
-
/**
|
|
88
|
-
* Dynamic import to lazy-load. The literal `import('./X')` is what
|
|
89
|
-
* Rolldown / Vite see when emitting chunks — using a variable here
|
|
90
|
-
* defeats code splitting.
|
|
91
|
-
*
|
|
92
|
-
* Typed as optional ONLY because the compiler-driven inline form
|
|
93
|
-
* (`<Defer when={x}><Modal /></Defer>`) doesn't include a `chunk`
|
|
94
|
-
* prop at source level — `@pyreon/compiler`'s `transformDeferInline`
|
|
95
|
-
* synthesizes it before runtime. Authors using the explicit form
|
|
96
|
-
* must pass `chunk` — runtime throws a clear dev-mode error when
|
|
97
|
-
* the trigger fires and `chunk` is missing.
|
|
98
|
-
*/
|
|
99
|
-
chunk?: () => Promise<ChunkResult<P>>
|
|
100
|
-
/**
|
|
101
|
-
* Children accept TWO shapes:
|
|
102
|
-
* 1. Render-prop `(Component) => VNodeChild` — the explicit form.
|
|
103
|
-
* Receives the loaded component, lets the author pass props.
|
|
104
|
-
* 2. Inline JSX (`<Defer when={x}><Modal /></Defer>`) — the compiler-
|
|
105
|
-
* driven form. The compiler extracts the subtree into a chunk
|
|
106
|
-
* and rewrites this to the render-prop form before runtime.
|
|
107
|
-
*
|
|
108
|
-
* Type widening is necessary because TypeScript checks the raw source
|
|
109
|
-
* BEFORE the compiler pass runs — both shapes must typecheck.
|
|
110
|
-
*/
|
|
111
|
-
children?: ((Component: ComponentFn<P>) => VNodeChild) | VNodeChild
|
|
112
|
-
/** Shown while the chunk is loading. Default: `null`. */
|
|
113
|
-
fallback?: VNodeChild
|
|
114
|
-
/**
|
|
115
|
-
* IntersectionObserver `rootMargin` for `on="visible"` mode. Default
|
|
116
|
-
* `'200px'` — start loading the chunk before the wrapper is fully in
|
|
117
|
-
* view so it's typically ready by the time the user scrolls to it.
|
|
118
|
-
*/
|
|
119
|
-
rootMargin?: string
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Lazy-load a chunk when a trigger condition is met.
|
|
124
|
-
*
|
|
125
|
-
* Three trigger modes:
|
|
126
|
-
* - `when={() => signal()}` — load when condition flips truthy (modal pattern)
|
|
127
|
-
* - `on="visible"` — load when the wrapper scrolls into view
|
|
128
|
-
* - `on="idle"` — load during browser idle time
|
|
129
|
-
*
|
|
130
|
-
* The chunk fetch is fired exactly once per `Defer` instance — repeated
|
|
131
|
-
* trigger firings after the chunk loads are no-ops.
|
|
132
|
-
*
|
|
133
|
-
* @example
|
|
134
|
-
* // Signal-driven (modal):
|
|
135
|
-
* <Defer chunk={() => import('./ConfirmDeleteModal')} when={open}>
|
|
136
|
-
* {Modal => <Modal onClose={() => setOpen(false)} />}
|
|
137
|
-
* </Defer>
|
|
138
|
-
*
|
|
139
|
-
* @example
|
|
140
|
-
* // Viewport-driven (below-fold):
|
|
141
|
-
* <Defer chunk={() => import('./Comments')} on="visible">
|
|
142
|
-
* {Comments => <Comments postId={id} />}
|
|
143
|
-
* </Defer>
|
|
144
|
-
*
|
|
145
|
-
* @example
|
|
146
|
-
* // Idle-driven (non-critical):
|
|
147
|
-
* <Defer chunk={() => import('./Analytics')} on="idle">
|
|
148
|
-
* {Dashboard => <Dashboard />}
|
|
149
|
-
* </Defer>
|
|
150
|
-
*/
|
|
151
|
-
export function Defer<P extends Props>(props: DeferProps<P>): VNode {
|
|
152
|
-
const Loaded = signal<ComponentFn<P> | null>(null)
|
|
153
|
-
const Failed = signal<Error | null>(null)
|
|
154
|
-
// Module-scope flag prevents repeat fetches when the trigger condition
|
|
155
|
-
// oscillates (e.g. modal opens / closes / opens again). The chunk only
|
|
156
|
-
// loads once per Defer mount.
|
|
157
|
-
let loadStarted = false
|
|
158
|
-
|
|
159
|
-
const startLoad = (): void => {
|
|
160
|
-
if (loadStarted) return
|
|
161
|
-
loadStarted = true
|
|
162
|
-
if (!props.chunk) {
|
|
163
|
-
// Missing chunk = either the user is hand-writing the inline form
|
|
164
|
-
// without the compiler pass running, or they wrote the explicit
|
|
165
|
-
// form and forgot to pass chunk. Either way, error early with an
|
|
166
|
-
// actionable message instead of crashing later inside the `.then`.
|
|
167
|
-
const err = new Error(
|
|
168
|
-
'[Pyreon] <Defer> has no `chunk` prop. Either pass `chunk={() => import("...")}` ' +
|
|
169
|
-
'(explicit form), or use the inline form `<Defer when={...}><Component /></Defer>` ' +
|
|
170
|
-
'with `@pyreon/vite-plugin` enabled — the compiler rewrites inline JSX to ' +
|
|
171
|
-
'an explicit chunk-prop call.',
|
|
172
|
-
)
|
|
173
|
-
Failed.set(err)
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
props
|
|
177
|
-
.chunk()
|
|
178
|
-
.then((mod) => {
|
|
179
|
-
// Accept both ES-module-default and bare ComponentFn shapes.
|
|
180
|
-
const Comp =
|
|
181
|
-
typeof mod === 'function'
|
|
182
|
-
? mod
|
|
183
|
-
: (mod as { default: ComponentFn<P> }).default
|
|
184
|
-
if (__DEV__ && typeof Comp !== 'function') {
|
|
185
|
-
// oxlint-disable-next-line no-console
|
|
186
|
-
console.warn(
|
|
187
|
-
'[Pyreon] <Defer> chunk() resolved without a default-exported component. Make sure your module exports default.',
|
|
188
|
-
)
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
Loaded.set(Comp)
|
|
192
|
-
})
|
|
193
|
-
.catch((err) => {
|
|
194
|
-
const wrapped = err instanceof Error ? err : new Error(String(err))
|
|
195
|
-
if (__DEV__) {
|
|
196
|
-
// oxlint-disable-next-line no-console
|
|
197
|
-
console.error('[Pyreon] <Defer> chunk() rejected:', wrapped)
|
|
198
|
-
}
|
|
199
|
-
Failed.set(wrapped)
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Trigger wiring — exactly one branch fires per instance.
|
|
204
|
-
if ('when' in props) {
|
|
205
|
-
// Signal-driven. Subscribe to the accessor; load when it transitions
|
|
206
|
-
// to truthy. Repeat truthy emissions are no-ops via `loadStarted`.
|
|
207
|
-
effect(() => {
|
|
208
|
-
if (props.when() && !loadStarted) startLoad()
|
|
209
|
-
})
|
|
210
|
-
} else if (props.on === 'idle') {
|
|
211
|
-
// Idle-driven. Delegated to `_setupIdleTrigger` so the browser-API
|
|
212
|
-
// branching is testable as a pure function. Wrapped in onMount so
|
|
213
|
-
// SSR / non-browser environments don't fire the callback at all.
|
|
214
|
-
onMount(() => _setupIdleTrigger(startLoad))
|
|
215
|
-
}
|
|
216
|
-
// Note: `on === 'visible'` is wired below alongside the wrapper element
|
|
217
|
-
// because it needs a DOM target to observe.
|
|
218
|
-
|
|
219
|
-
// Inline accessor — type annotation deliberately omitted so the
|
|
220
|
-
// inferred return type narrows to `VNodeChildAtom | VNodeChildAtom[]`
|
|
221
|
-
// (what `h()`'s rest-args expect). Annotating as `VNodeChild` widens
|
|
222
|
-
// to include `VNodeChildAccessor`, which can't be returned from another
|
|
223
|
-
// accessor.
|
|
224
|
-
const renderContent = () => {
|
|
225
|
-
const err = Failed()
|
|
226
|
-
if (err) throw err
|
|
227
|
-
const Comp = Loaded()
|
|
228
|
-
if (!Comp) return props.fallback ?? null
|
|
229
|
-
// children is widened to `VNodeChild | render-prop` so the compiler-
|
|
230
|
-
// driven inline form (where author writes `<Defer ...><Modal /></Defer>`)
|
|
231
|
-
// typechecks at source level. At RUNTIME children is always either
|
|
232
|
-
// undefined OR the render-prop — the compiler rewrites the inline
|
|
233
|
-
// form's JSX children to a render-prop before this code runs.
|
|
234
|
-
// A non-function children at runtime means the user is invoking the
|
|
235
|
-
// inline form without the compiler pass (e.g. running tests through
|
|
236
|
-
// a bundler that doesn't include `@pyreon/vite-plugin`) — in that
|
|
237
|
-
// case we render `<Comp />` with no props as a best-effort fallback.
|
|
238
|
-
const ch = props.children
|
|
239
|
-
if (typeof ch === 'function') return ch(Comp)
|
|
240
|
-
return h(Comp as ComponentFn, {})
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if ('on' in props && props.on === 'visible') {
|
|
244
|
-
// Visible-mode needs a DOM target for IntersectionObserver. A
|
|
245
|
-
// wrapper `<div data-pyreon-defer="visible">` carries the ref and
|
|
246
|
-
// styles `display: contents` so it's transparent to layout (the
|
|
247
|
-
// fallback / loaded component render as direct children of Defer's
|
|
248
|
-
// parent).
|
|
249
|
-
const containerRef = createRef<HTMLElement>()
|
|
250
|
-
// Visible-mode trigger is wired via `_setupVisibleTrigger` so the
|
|
251
|
-
// observer-construction + intersection-detection logic is
|
|
252
|
-
// independently testable. onMount keeps the browser-API access
|
|
253
|
-
// out of the SSR path.
|
|
254
|
-
onMount(() =>
|
|
255
|
-
_setupVisibleTrigger(
|
|
256
|
-
containerRef.current,
|
|
257
|
-
startLoad,
|
|
258
|
-
props.rootMargin ?? '200px',
|
|
259
|
-
),
|
|
260
|
-
)
|
|
261
|
-
// Cast renderContent to VNodeChildAccessor — its inferred return type
|
|
262
|
-
// is `VNodeChild` (broader than the accessor's `atom | atom[]`) because
|
|
263
|
-
// `props.children` itself may return any VNodeChild. The runtime
|
|
264
|
-
// unwraps nested accessors via the same mountChild path that handles
|
|
265
|
-
// <Show>'s thunk shape; the type system doesn't model the unwrap so
|
|
266
|
-
// the cast bridges. See <Show>'s `as unknown as VNode` for prior art.
|
|
267
|
-
return h(
|
|
268
|
-
'div',
|
|
269
|
-
{
|
|
270
|
-
'data-pyreon-defer': 'visible',
|
|
271
|
-
ref: containerRef,
|
|
272
|
-
style: 'display: contents',
|
|
273
|
-
},
|
|
274
|
-
renderContent as VNodeChildAccessor,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return h(Fragment, null, renderContent as VNodeChildAccessor)
|
|
279
|
-
}
|
package/src/dynamic.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { h } from './h'
|
|
2
|
-
import type { ComponentFn, Props, VNode, VNodeChild } from './types'
|
|
3
|
-
|
|
4
|
-
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
5
|
-
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
6
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
7
|
-
|
|
8
|
-
export interface DynamicProps extends Props {
|
|
9
|
-
component: ComponentFn | string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function Dynamic(props: DynamicProps): VNode | null {
|
|
13
|
-
const { component, children, ...rest } = props as DynamicProps & { children?: unknown }
|
|
14
|
-
if (__DEV__ && !component) {
|
|
15
|
-
// oxlint-disable-next-line no-console
|
|
16
|
-
console.warn('[Pyreon] <Dynamic> received a falsy `component` prop. Nothing will be rendered.')
|
|
17
|
-
}
|
|
18
|
-
if (!component) return null
|
|
19
|
-
// Children must NOT remain in props. When `component` is a string tag
|
|
20
|
-
// (e.g. <Dynamic component="h3">x</Dynamic>), runtime-dom's prop applier
|
|
21
|
-
// forwards every prop key to setAttribute, so a leaked `children` prop
|
|
22
|
-
// crashes with `setAttribute('children', ...)`. Re-emit them as h() rest
|
|
23
|
-
// args so they land in vnode.children, which is where both string-tag
|
|
24
|
-
// mounts and component-merge expect them.
|
|
25
|
-
if (children === undefined) {
|
|
26
|
-
return h(component as string | ComponentFn, rest as Props)
|
|
27
|
-
}
|
|
28
|
-
if (Array.isArray(children)) {
|
|
29
|
-
return h(component as string | ComponentFn, rest as Props, ...(children as VNodeChild[]))
|
|
30
|
-
}
|
|
31
|
-
return h(component as string | ComponentFn, rest as Props, children as VNodeChild)
|
|
32
|
-
}
|
package/src/env.d.ts
DELETED