@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.
Files changed (56) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +53 -31
  3. package/package.json +2 -6
  4. package/src/compat-marker.ts +0 -79
  5. package/src/compat-shared.ts +0 -80
  6. package/src/component.ts +0 -98
  7. package/src/context.ts +0 -349
  8. package/src/defer.ts +0 -279
  9. package/src/dynamic.ts +0 -32
  10. package/src/env.d.ts +0 -6
  11. package/src/error-boundary.ts +0 -90
  12. package/src/for.ts +0 -51
  13. package/src/h.ts +0 -80
  14. package/src/index.ts +0 -80
  15. package/src/jsx-dev-runtime.ts +0 -2
  16. package/src/jsx-runtime.ts +0 -747
  17. package/src/lazy.ts +0 -25
  18. package/src/lifecycle.ts +0 -152
  19. package/src/manifest.ts +0 -579
  20. package/src/map-array.ts +0 -42
  21. package/src/portal.ts +0 -39
  22. package/src/props.ts +0 -269
  23. package/src/ref.ts +0 -32
  24. package/src/show.ts +0 -121
  25. package/src/style.ts +0 -102
  26. package/src/suspense.ts +0 -52
  27. package/src/telemetry.ts +0 -120
  28. package/src/tests/compat-marker.test.ts +0 -96
  29. package/src/tests/compat-shared.test.ts +0 -99
  30. package/src/tests/component.test.ts +0 -281
  31. package/src/tests/context.test.ts +0 -629
  32. package/src/tests/core.test.ts +0 -1290
  33. package/src/tests/cx.test.ts +0 -70
  34. package/src/tests/defer.test.ts +0 -359
  35. package/src/tests/dynamic.test.ts +0 -87
  36. package/src/tests/error-boundary.test.ts +0 -181
  37. package/src/tests/extract-props-overloads.types.test.ts +0 -135
  38. package/src/tests/for.test.ts +0 -117
  39. package/src/tests/h.test.ts +0 -221
  40. package/src/tests/jsx-compat.test.tsx +0 -86
  41. package/src/tests/lazy.test.ts +0 -100
  42. package/src/tests/lifecycle.test.ts +0 -350
  43. package/src/tests/manifest-snapshot.test.ts +0 -100
  44. package/src/tests/map-array.test.ts +0 -313
  45. package/src/tests/native-marker-error-boundary.test.ts +0 -12
  46. package/src/tests/portal.test.ts +0 -48
  47. package/src/tests/props-extended.test.ts +0 -157
  48. package/src/tests/props.test.ts +0 -250
  49. package/src/tests/reactive-context.test.ts +0 -69
  50. package/src/tests/reactive-props.test.ts +0 -157
  51. package/src/tests/ref.test.ts +0 -70
  52. package/src/tests/show.test.ts +0 -314
  53. package/src/tests/style.test.ts +0 -157
  54. package/src/tests/suspense.test.ts +0 -139
  55. package/src/tests/telemetry.test.ts +0 -297
  56. 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
@@ -1,6 +0,0 @@
1
- /**
2
- * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
- * Avoids requiring @types/node in consumers that import pyreon source
4
- * via the `"bun"` export condition.
5
- */
6
- declare var process: { env: { NODE_ENV?: string } }