@pyreon/core 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,98 +1,4 @@
1
- //#region src/h.ts
2
- /**
3
- * Marker for fragment nodes — renders children without a wrapper element.
4
- *
5
- * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
6
- * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
7
- * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
8
- * each bundle's evaluation of a bare `Symbol(...)` would produce a
9
- * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
10
- * resolves to jsx-runtime's identity; `runtime-server` checks
11
- * `vnode.type === Fragment` against the main-entry identity. Mismatch
12
- * fell through to `renderElement` and crashed SSG with
13
- * `TypeError: Cannot convert a Symbol value to a string`.
14
- * `Symbol.for()` keys by string in a global registry shared across all
15
- * bundle evaluations — same identity everywhere.
16
- */
17
- const Fragment = Symbol.for("Pyreon.Fragment");
18
- /**
19
- * Hyperscript function — the compiled output of JSX.
20
- * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
21
- *
22
- * Generic on P so TypeScript validates props match the component's signature
23
- * at the call site, then stores the result in the loosely-typed VNode.
24
- */
25
- /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
26
- const EMPTY_PROPS = {};
27
- function h(type, props, ...children) {
28
- return {
29
- type,
30
- props: props ?? EMPTY_PROPS,
31
- children: normalizeChildren(children),
32
- key: props?.key ?? null
33
- };
34
- }
35
- function normalizeChildren(children) {
36
- for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
37
- return children;
38
- }
39
- function flattenChildren(children) {
40
- const result = [];
41
- for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
42
- else result.push(child);
43
- return result;
44
- }
1
+ import { n as Fragment } from "./_chunks/h-CYSD6aBx.js";
2
+ import { jsx, jsxs } from "./jsx-runtime.js";
45
3
 
46
- //#endregion
47
- //#region src/jsx-runtime.ts
48
- /**
49
- * JSX automatic runtime.
50
- *
51
- * When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
52
- * rewrites JSX to imports from this file automatically:
53
- * <div class="x" /> → jsx("div", { class: "x" })
54
- *
55
- * The triple-slash reference above makes this file self-declare its DOM-lib
56
- * dependency. Without it, any consumer whose tsconfig has `lib: ["ESNext"]`
57
- * (no DOM) — e.g. backend-only packages like @pyreon/cli — fails to typecheck
58
- * once `@pyreon/core` becomes resolvable from their dependency graph (e.g. via
59
- * a transitive devDep), because tsc auto-resolves jsxImportSource and pulls
60
- * jsx-runtime.ts into the consumer's compilation unit.
61
- */
62
- function jsx(type, props, key) {
63
- const descriptors = Object.getOwnPropertyDescriptors(props);
64
- let hasGetter = false;
65
- for (const k in descriptors) if (descriptors[k].get) {
66
- hasGetter = true;
67
- break;
68
- }
69
- const children = props.children;
70
- if (!hasGetter) {
71
- const { children: _ignored, ...rest } = props;
72
- const propsWithKey = key != null ? {
73
- ...rest,
74
- key
75
- } : rest;
76
- if (typeof type === "function") return h(type, children !== void 0 ? {
77
- ...propsWithKey,
78
- children
79
- } : propsWithKey);
80
- return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
81
- }
82
- const propsWithKey = {};
83
- for (const k in descriptors) {
84
- if (k === "children") continue;
85
- Object.defineProperty(propsWithKey, k, descriptors[k]);
86
- }
87
- if (key != null) propsWithKey.key = key;
88
- if (typeof type === "function") {
89
- if (children !== void 0) propsWithKey.children = children;
90
- return h(type, propsWithKey);
91
- }
92
- return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
93
- }
94
- const jsxs = jsx;
95
-
96
- //#endregion
97
- export { Fragment, jsx as jsxDEV, jsxs };
98
- //# sourceMappingURL=jsx-dev-runtime.js.map
4
+ export { Fragment, jsx as jsxDEV, jsxs };
@@ -1,49 +1,5 @@
1
- //#region src/h.ts
2
- /**
3
- * Marker for fragment nodes — renders children without a wrapper element.
4
- *
5
- * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
6
- * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
7
- * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
8
- * each bundle's evaluation of a bare `Symbol(...)` would produce a
9
- * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
10
- * resolves to jsx-runtime's identity; `runtime-server` checks
11
- * `vnode.type === Fragment` against the main-entry identity. Mismatch
12
- * fell through to `renderElement` and crashed SSG with
13
- * `TypeError: Cannot convert a Symbol value to a string`.
14
- * `Symbol.for()` keys by string in a global registry shared across all
15
- * bundle evaluations — same identity everywhere.
16
- */
17
- const Fragment = Symbol.for("Pyreon.Fragment");
18
- /**
19
- * Hyperscript function — the compiled output of JSX.
20
- * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
21
- *
22
- * Generic on P so TypeScript validates props match the component's signature
23
- * at the call site, then stores the result in the loosely-typed VNode.
24
- */
25
- /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
26
- const EMPTY_PROPS = {};
27
- function h(type, props, ...children) {
28
- return {
29
- type,
30
- props: props ?? EMPTY_PROPS,
31
- children: normalizeChildren(children),
32
- key: props?.key ?? null
33
- };
34
- }
35
- function normalizeChildren(children) {
36
- for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
37
- return children;
38
- }
39
- function flattenChildren(children) {
40
- const result = [];
41
- for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
42
- else result.push(child);
43
- return result;
44
- }
1
+ import { n as Fragment, r as h } from "./_chunks/h-CYSD6aBx.js";
45
2
 
46
- //#endregion
47
3
  //#region src/jsx-runtime.ts
48
4
  /**
49
5
  * JSX automatic runtime.
@@ -247,7 +247,34 @@ declare function createReactiveContext<T>(defaultValue: T): ReactiveContext<T>;
247
247
  */
248
248
  declare function setContextStackProvider(fn: () => Map<symbol, unknown>[]): void;
249
249
  declare function pushContext(values: Map<symbol, unknown>): void;
250
+ /**
251
+ * Pop the LAST frame from the context stack.
252
+ *
253
+ * NOTE: position-based pop. Safe ONLY when the caller can guarantee that the
254
+ * top of the stack is the frame they want to remove (the strict LIFO contract).
255
+ * The `provide()` helper does NOT use this — it uses identity-based removal
256
+ * via `removeContextFrame` because reactive boundaries can push snapshot
257
+ * frames between a component's `provide(ctx, value)` and its eventual
258
+ * unmount, making the top-of-stack unsafe to assume.
259
+ */
250
260
  declare function popContext(): void;
261
+ /**
262
+ * Remove a SPECIFIC frame from the context stack by reference identity.
263
+ *
264
+ * Internal — used by `provide()` and `withContext()` to safely clean up
265
+ * their pushed frame on unmount even when other frames have been pushed
266
+ * between push and pop (e.g. a reactive boundary's `restoreContextStack`
267
+ * pushing snapshot frames during the descendant's lifecycle). The
268
+ * symmetric position-based `popContext()` would pop the wrong frame in
269
+ * that case and orphan the descendant's provider frame on the live stack
270
+ * — the root cause of the 321k-entry context-stack leak under repeated
271
+ * reactive remounts.
272
+ *
273
+ * Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
274
+ * with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
275
+ * by two unmounts removes them in reverse order.
276
+ */
277
+ declare function removeContextFrame(frame: Map<symbol, unknown>): void;
251
278
  /**
252
279
  * Read the nearest provided value for a context.
253
280
  * Falls back to `context.defaultValue` if none found.
@@ -1416,5 +1443,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
1416
1443
  */
1417
1444
  declare function reportError(ctx: ErrorContext): void;
1418
1445
  //#endregion
1419
- export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
1446
+ export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, removeContextFrame, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
1420
1447
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/core",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Core component model and lifecycle for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/core#readme",
6
6
  "bugs": {
@@ -53,7 +53,7 @@
53
53
  "prepublishOnly": "bun run build"
54
54
  },
55
55
  "dependencies": {
56
- "@pyreon/reactivity": "^0.21.0"
56
+ "@pyreon/reactivity": "^0.23.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@pyreon/manifest": "0.13.1"
package/src/component.ts CHANGED
@@ -46,6 +46,22 @@ export function propagateError(err: unknown, hooks: LifecycleHooks): boolean {
46
46
  // Module-level stack of active ErrorBoundary handlers (innermost last).
47
47
  // ErrorBoundary pushes during its own setup (before children mount) so that
48
48
  // any child mountComponent error can dispatch up to the nearest boundary.
49
+ //
50
+ // Mutation contract: removal is IDENTITY-based (`lastIndexOf + splice`), not
51
+ // position-based (`pop`). Sibling boundaries unmount in an order that's
52
+ // driven by the renderer (keyed `<For>` reconciliation, `<Show>` flips,
53
+ // route nav), NOT in strict LIFO push order. A position-based `pop()` would
54
+ // remove the wrong frame whenever the unmount order diverges from the push
55
+ // order — the first boundary's `onUnmount` would pop the last boundary's
56
+ // handler, orphaning the first boundary's handler on the stack and removing
57
+ // the surviving boundary's handler from it. Subsequent errors would then
58
+ // route to the orphan (whose owning boundary's signal is already disposed,
59
+ // so the error vanishes silently) and the surviving boundary's children's
60
+ // errors would fall through to whichever boundary happens to sit at
61
+ // `stack[length-1]`. Same root-cause shape as the `popContext()` bug
62
+ // fixed in #725 for `provide()` — see
63
+ // `.claude/rules/anti-patterns.md` "Position-based pop for stack frames
64
+ // that may be pushed by reactive boundaries".
49
65
 
50
66
  const _errorBoundaryStack: ((err: unknown) => boolean)[] = []
51
67
 
@@ -53,8 +69,23 @@ export function pushErrorBoundary(handler: (err: unknown) => boolean): void {
53
69
  _errorBoundaryStack.push(handler)
54
70
  }
55
71
 
56
- export function popErrorBoundary(): void {
57
- _errorBoundaryStack.pop()
72
+ /**
73
+ * Remove a SPECIFIC handler from the error-boundary stack by reference
74
+ * identity. Each `ErrorBoundary` registers `onUnmount(() => popErrorBoundary(handler))`
75
+ * with its OWN handler — so unmount in any order (LIFO, FIFO, middle-out)
76
+ * correctly removes the right handler.
77
+ */
78
+ export function popErrorBoundary(handler?: (err: unknown) => boolean): void {
79
+ if (handler === undefined) {
80
+ // Back-compat: legacy callers that don't pass a handler get the old
81
+ // pop-last behaviour. Internal `ErrorBoundary` setup always passes
82
+ // its handler now; any external direct callers (tests, advanced
83
+ // consumers) keep working with no-arg form.
84
+ _errorBoundaryStack.pop()
85
+ return
86
+ }
87
+ const idx = _errorBoundaryStack.lastIndexOf(handler)
88
+ if (idx !== -1) _errorBoundaryStack.splice(idx, 1)
58
89
  }
59
90
 
60
91
  /**
package/src/context.ts CHANGED
@@ -73,12 +73,44 @@ export function pushContext(values: Map<symbol, unknown>) {
73
73
  getStack().push(values)
74
74
  }
75
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
+ */
76
86
  export function popContext() {
77
87
  const stack = getStack()
78
88
  if (stack.length === 0) return
79
89
  stack.pop()
80
90
  }
81
91
 
92
+ /**
93
+ * Remove a SPECIFIC frame from the context stack by reference identity.
94
+ *
95
+ * Internal — used by `provide()` and `withContext()` to safely clean up
96
+ * their pushed frame on unmount even when other frames have been pushed
97
+ * between push and pop (e.g. a reactive boundary's `restoreContextStack`
98
+ * pushing snapshot frames during the descendant's lifecycle). The
99
+ * symmetric position-based `popContext()` would pop the wrong frame in
100
+ * that case and orphan the descendant's provider frame on the live stack
101
+ * — the root cause of the 321k-entry context-stack leak under repeated
102
+ * reactive remounts.
103
+ *
104
+ * Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
105
+ * with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
106
+ * by two unmounts removes them in reverse order.
107
+ */
108
+ export function removeContextFrame(frame: Map<symbol, unknown>): void {
109
+ const stack = getStack()
110
+ const idx = stack.lastIndexOf(frame)
111
+ if (idx !== -1) stack.splice(idx, 1)
112
+ }
113
+
82
114
  /**
83
115
  * Read the nearest provided value for a context.
84
116
  * Falls back to `context.defaultValue` if none found.
@@ -111,8 +143,17 @@ export function useContext<T>(context: Context<T>): T {
111
143
  * }
112
144
  */
113
145
  export function provide<T>(context: Context<T>, value: T): void {
114
- pushContext(new Map<symbol, unknown>([[context.id, value]]))
115
- onUnmount(() => popContext())
146
+ const frame = new Map<symbol, unknown>([[context.id, value]])
147
+ pushContext(frame)
148
+ // Identity-based removal — the top of the stack is NOT guaranteed to be
149
+ // this frame at unmount time. Reactive boundaries (`mountReactive`'s
150
+ // effect snapshot-restore + the inner `restoreContextStack` call) push
151
+ // additional snapshot frames during a descendant's lifecycle. A
152
+ // position-based `popContext()` would pop the snapshot frame instead
153
+ // of this provider's frame and orphan the provider on the live stack.
154
+ // See `.claude/rules/anti-patterns.md` "Context-stack frame identity"
155
+ // for the full bug class.
156
+ onUnmount(() => removeContextFrame(frame))
116
157
  }
117
158
 
118
159
  /**
@@ -125,7 +166,11 @@ export function withContext<T>(context: Context<T>, value: T, fn: () => void) {
125
166
  try {
126
167
  fn()
127
168
  } finally {
128
- popContext()
169
+ // Same identity-based-removal rationale as `provide()` — `fn()` may
170
+ // synchronously trigger a `mountReactive` re-run whose snapshot-restore
171
+ // window leaves the top-of-stack pointing at a snapshot push, not our
172
+ // frame.
173
+ removeContextFrame(frame)
129
174
  }
130
175
  }
131
176
 
@@ -162,7 +207,6 @@ export function captureContextStack(): ContextSnapshot {
162
207
  */
163
208
  export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T {
164
209
  const stack = getStack()
165
- const insertIndex = stack.length
166
210
 
167
211
  // Push captured snapshot frames at the END of the current stack.
168
212
  for (const frame of snapshot) {
@@ -172,15 +216,27 @@ export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T):
172
216
  try {
173
217
  return fn()
174
218
  } finally {
175
- // Splice out exactly the snapshot frames we pushed (they sit at
176
- // [insertIndex, insertIndex + snapshot.length)). Any frames `fn()`
177
- // pushed AFTER our snapshot (provider frames) get shifted down by
178
- // `snapshot.length` positions but remain on the stack. Their owning
179
- // components' `onUnmount(popContext)` handlers will pop them in
180
- // LIFO order on subtree teardown splice preserves that ordering
181
- // because it doesn't touch frames at indices >= insertIndex +
182
- // snapshot.length until the splice operation itself.
183
- stack.splice(insertIndex, snapshot.length)
219
+ // Remove our pushed snapshot frames by REFERENCE IDENTITY (not by
220
+ // position). `fn()` may legitimately remove frames at indices BEFORE
221
+ // our push window most commonly via `provide()` registering
222
+ // `onUnmount(removeContextFrame(frame))` and a descendant unmount
223
+ // firing inside this restore window. A position-based `splice` would
224
+ // either pull the wrong frames or no-op when the live stack has
225
+ // shrunk below the original `insertIndex + snapshot.length`
226
+ // orphaning the snapshot pushes on the live stack and producing the
227
+ // 321k-frame leak reported under repeated reactive remounts.
228
+ //
229
+ // Iterate in reverse so multi-occurrence frames (the same Map ref
230
+ // pushed by multiple nested restores) are removed in LIFO push order.
231
+ // `lastIndexOf` is O(N); N is small in practice (single-digit nesting),
232
+ // and the alternative `findLastIndex(f => f === frame)` is the same
233
+ // cost.
234
+ for (let i = snapshot.length - 1; i >= 0; i--) {
235
+ const frame = snapshot[i]
236
+ if (!frame) continue
237
+ const idx = stack.lastIndexOf(frame)
238
+ if (idx !== -1) stack.splice(idx, 1)
239
+ }
184
240
  }
185
241
  }
186
242
 
@@ -68,7 +68,13 @@ export function ErrorBoundary(props: {
68
68
 
69
69
  // Push synchronously — before children are mounted — so child errors see this boundary
70
70
  pushErrorBoundary(handler)
71
- onUnmount(() => popErrorBoundary())
71
+ // Identity-based pop: pass our own handler reference. Sibling boundaries
72
+ // can unmount in any order driven by the renderer (keyed `<For>` removal
73
+ // of a non-last item, `<Show>` flipping on the FIRST of N siblings, route
74
+ // nav, etc.) — without passing the handler reference, the position-based
75
+ // `pop()` would remove the WRONG boundary's handler. Same bug class as
76
+ // #725 (`popContext()` orphaning provider frames under reactive remount).
77
+ onUnmount(() => popErrorBoundary(handler))
72
78
 
73
79
  return (): VNodeChildAtom => {
74
80
  const err = error()
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  popContext,
12
12
  provide,
13
13
  pushContext,
14
+ removeContextFrame,
14
15
  restoreContextStack,
15
16
  setContextStackProvider,
16
17
  useContext,