diff --git a/.changeset/cleanup-throw-continues.md b/.changeset/cleanup-throw-continues.md new file mode 100644 index 000000000..c60fb1537 --- /dev/null +++ b/.changeset/cleanup-throw-continues.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +--- + +A cleanup (`onCleanup` or an effect's returned cleanup) that throws no longer aborts the rest of the teardown: every other cleanup in the tree still runs, and the first error is rethrown once the teardown is done. Outside a flush (a root's `dispose()`), the dispose call rethrows it. Inside a flush (a branch switch retiring a frame, a pass replacing an uncommitted one, an effect disposing a root), `flush()` rethrows it after the flush has finished, so commits and effects are never left half-applied, and lazy nodes released before the throw are still released. An `unobserved` hook that throws is handled the same way. If an uncaught error halts the reactive system, a collected cleanup error is dropped rather than surfacing later from an unrelated `dispose()`. On the server, `disposeOwner` runs every cleanup the same way, so `renderToStream`'s final dispose still reports the error (#4193). diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 4cb92f61f..e5c43f972 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -406,14 +406,13 @@ export function recompute(el: Computed, create: boolean = false): void { // and own nothing to park. if (isEffect === EFFECT_TRACKED || el._config & CONFIG_STAGED) { // A lazy node only those children read is released when this pass - // ends, if the pass doesn't read it again (#4142). + // ends, if the pass doesn't read it again (#4142). A cleanup or + // `unobserved` hook that throws here neither stops the pass nor skips + // that drain: its error is rethrown when the flush ends (owner.ts). const prev = deferredReleases; deferReleases((released = [])); - try { - disposeChildren(el); - } finally { - deferReleases(prev); - } + disposeChildren(el); + deferReleases(prev); } else if (el._firstChild !== null || el._disposal !== null) parkChildren(el); // An error a pass staged (D-41) is replaced by this pass's answer: a // value, a flight, or an error of its own (#4109, #4145). @@ -1510,8 +1509,9 @@ export function exitCallback(): void { * its owner (a list pass removing a row, a branch switching away), so its * writes are imperative like an effect callback's (#4165). Kept apart from * `callbackDepth` because its sites cannot use try/finally (the frame would - * survive into prod): a throwing cleanup leaves it raised, and dev.ts resets - * it on the next microtask, which teardown — synchronous — never spans. + * survive into prod). A throwing cleanup is caught (owner.ts `runCleanup`); + * anything else escaping a teardown leaves it raised, and dev.ts resets it on + * the next microtask, which teardown — synchronous — never spans. */ export let disposalDepth = 0; export function enterDisposal(): void { diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 8c691dc1a..45b6f6902 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -1267,9 +1267,10 @@ export function checkPostAwaitRead( if (callbackDepth !== 0) return; if (disposalDepth !== 0) { // Teardown is synchronous, so a depth still raised on the next microtask - // was left behind by a cleanup that threw (owner.ts brackets it without - // try/finally, which would survive into prod). Clear it then, or every - // later check would stay silent. + // was left behind by a throw that escaped it (owner.ts brackets it + // without try/finally, which would survive into prod; a cleanup's own + // throw is caught there). Clear it then, or every later check would stay + // silent. if (!disposalHealQueued) { disposalHealQueued = true; queueMicrotask(healDisposalDepth); diff --git a/packages/signals/src/core/graph.ts b/packages/signals/src/core/graph.ts index 3a00bd4a9..c21fe3ef3 100644 --- a/packages/signals/src/core/graph.ts +++ b/packages/signals/src/core/graph.ts @@ -7,7 +7,7 @@ import { STATUS_PENDING } from "./constants.js"; import { deleteFromHeap } from "./heap.js"; -import { disposeChildren } from "./owner.js"; +import { disposeChildren, runCleanup } from "./owner.js"; import { bumpNotifyEpoch, dirtyQueue } from "./scheduler.js"; import type { Computed, Link, Signal } from "./types.js"; @@ -35,8 +35,10 @@ export function unlinkSubs(link: Link): Link | null { if (nextSub === null) { // Store slot nodes dispatch to the ONE shared hook — no per-node // unobserved closure, no NodeExtension to hold it. + // A user hook that throws is collected like a cleanup (owner.ts): the + // unlink and the teardown around it carry on. if (dep._config & CONFIG_SLOT_NODE) slotUnobservedHook(dep as Signal); - else dep._x?._unobserved?.(); + else if (dep._x?._unobserved) runCleanup(dep._x._unobserved); // No more subscribers; only tear down if CONFIG_AUTO_DISPOSE is set. // A pending node is exempt: its in-flight async work is an observer — // tearing down would orphan the work and re-execute it on the next diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index 1fec7f904..476737344 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -29,7 +29,7 @@ import { } from "./dev.js"; import { clearDeps, unobserved } from "./graph.js"; import { deleteFromHeap } from "./heap.js"; -import { dirtyQueue, schedule } from "./scheduler.js"; +import { dirtyQueue, globalQueue, schedule } from "./scheduler.js"; import type { Computed, Disposable, Owner, Root } from "./types.js"; // L2 (step 2): the parked frame. A pass parks the previous frame's children @@ -60,6 +60,32 @@ export function dispose(node: Computed): void { unobserved(node); } +// The first error a cleanup threw, boxed so a thrown `undefined` still +// counts. A throwing cleanup stops neither its siblings nor the rest of the +// tree: every cleanup runs. The error is rethrown once the teardown is over: +// by a root's dispose call outside a flush (`disposeRootSelf`), otherwise when +// the flush ends (`flush`; `runCleanup` schedules one), so a flush is never +// left half-applied. A halt drops it: the error that halted is the one +// reported, and a stale one must not surface from a later, unrelated dispose. +let cleanupError: [unknown] | undefined; + +/** Runs a cleanup or `unobserved` hook; a throw is collected, not raised. @internal */ +export function runCleanup(fn: () => void): void { + try { + fn(); + } catch (e) { + cleanupError ||= [e]; + schedule(); + } +} + +/** Rethrows the error `runCleanup` collected, once; `drop` discards it. @internal */ +export function rethrowCleanupError(drop?: boolean): void { + const error = cleanupError; + cleanupError = undefined; + if (error && !drop) throw error[0]; +} + export function disposeChildren(node: Owner, self: boolean = false, zombie?: boolean): void { const flags = (node as any)._flags; if (flags & REACTIVE_DISPOSED) return; @@ -163,7 +189,7 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo const effectCleanup = node._cleanup; node._cleanup = undefined; if (__DEV__) enterDisposal(); - effectCleanup(); + runCleanup(effectCleanup); if (__DEV__) exitDisposal(); } } @@ -188,9 +214,7 @@ function runDisposal(node: Owner, zombie?: boolean): void { if (zombie) node._x!._pendingDisposal = null; else node._disposal = null; - // No try/finally: it would survive into prod (rollup keeps the frame). A - // throw leaves the depth raised; dev.ts clears the stale count on the next - // microtask, since teardown never spans one (core.ts). + // Each cleanup is guarded (`runCleanup`), so the depth is always lowered. if (__DEV__) enterDisposal(); if (Array.isArray(disposal)) { // Unwind order (#3572, restores 1.x #1562): later registrations run @@ -198,11 +222,10 @@ function runDisposal(node: Owner, zombie?: boolean): void { // so with LIFO a body that registers cleanup before creating its children // tears down after them — the same order a per-component owner gives. for (let i = disposal.length - 1; i >= 0; i--) { - const callable = disposal[i]; - callable.call(callable); + runCleanup(disposal[i]); } } else { - disposal.call(disposal); + runCleanup(disposal); } if (__DEV__) exitDisposal(); } @@ -335,6 +358,9 @@ export function isDisposed(node: Owner): boolean { function disposeRootSelf(this: Root, self: boolean = true): void { disposeChildren(this, self); + // Outside a flush the dispose call rethrows; inside one, the flush does. + // Kept out of `disposeChildren`, whose hot path it slows. + if (cleanupError && !globalQueue._running) rethrowCleanupError(); } /** diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index d20b2d844..4b913da2f 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -43,6 +43,7 @@ import { import { sweepDormant, trimStaleDeps } from "./graph.js"; import { deleteFromHeap, enqueueSub, runHeap, type Heap } from "./heap.js"; import { devCheckQuiescent } from "./invariants.js"; +import { rethrowCleanupError } from "./owner.js"; import type { Computed, Owner, Signal } from "./types.js"; // Optional-module hooks: module bindings, not `GlobalQueue` statics, so a @@ -702,6 +703,8 @@ export const ROOT_ERROR_HOOK: unique symbol = Symbol.for("solid-js/root-error-ho export function haltReactivity(cause?: unknown): void { if (halted) return; halted = true; + // A cleanup error collected before the halt is dropped (owner.ts). + rethrowCleanupError(true); let message = "[REACTIVITY_HALTED]"; if (__DEV__) { message += @@ -1459,6 +1462,7 @@ export function flush(fn?: () => T): T | void { if (__OBSERVE__ && attrHooks !== null && scheduled) attrHooks.flushStart(); while (scheduled) { if (__DEV__ && ++count === 1e5) { + rethrowCleanupError(true); // Attribution beats a bare guard (#3140): say what kept the loop alive. throw new Error( `Potential Infinite Loop Detected. Kept alive by scheduled work${ @@ -1474,6 +1478,9 @@ export function flush(fn?: () => T): T | void { // so this is the "committed and effects ran" instant for everything the // loop processed. if (__OBSERVE__ && drained && attrHooks !== null) attrHooks.flushEnd(); + // A cleanup that threw during the drain, now that the drain is complete + // (owner.ts). + rethrowCleanupError(); } function runQueue(queue: QueueCallback[], type: number): void { diff --git a/packages/signals/tests/cleanup-throw-continues.test.ts b/packages/signals/tests/cleanup-throw-continues.test.ts new file mode 100644 index 000000000..a79297e8c --- /dev/null +++ b/packages/signals/tests/cleanup-throw-continues.test.ts @@ -0,0 +1,292 @@ +// A cleanup that throws does not abort the rest of the teardown: every +// cleanup still runs — the ones registered before it on the same owner, the +// siblings and ancestors the walk had not reached, effect cleanups — and the +// first error is rethrown once the teardown is over. A teardown inside a flush +// (a commit retiring a frame, a pass replacing one, an effect disposing a +// root) leaves it for the end of the flush, so the flush is never left +// half-applied. +import { + createEffect, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + getOwner, + isDisposed, + onCleanup, + resetErrorHalt, + type Owner +} from "../src/index.js"; + +afterEach(() => { + resetErrorHalt(); + flush(); +}); + +const boom = (message = "boom") => { + throw new Error(message); +}; + +describe("root dispose", () => { + it("runs every cleanup in the tree and rethrows the first error after", () => { + const log: string[] = []; + let inner!: Owner; + let sibling!: Owner; + const dispose = createRoot(dispose => { + onCleanup(() => log.push("root")); + createRoot(() => { + inner = getOwner()!; + onCleanup(() => log.push("inner first")); + createRenderEffect( + () => 1, + () => () => log.push("effect cleanup") + ); + onCleanup(() => boom("first error")); + onCleanup(() => log.push("inner last")); + }); + createRoot(() => { + sibling = getOwner()!; + onCleanup(() => boom("second error")); + onCleanup(() => log.push("sibling")); + }); + return dispose; + }); + flush(); + + // Children go newest first, so the sibling's error is the first thrown. + expect(dispose).toThrow("second error"); + // Unwind order is kept (children, then the owner's own cleanups, latest + // first); nothing is skipped. + expect(log).toEqual(["sibling", "effect cleanup", "inner last", "inner first", "root"]); + expect(isDisposed(inner)).toBe(true); + expect(isDisposed(sibling)).toBe(true); + }); + + it("rethrows once: a later teardown does not see the error again", () => { + const first = createRoot(dispose => { + onCleanup(() => boom()); + return dispose; + }); + const second = createRoot(dispose => { + onCleanup(() => {}); + return dispose; + }); + expect(first).toThrow("boom"); + expect(second).not.toThrow(); + expect(() => flush()).not.toThrow(); + }); + + it("a throwing effect cleanup does not stop the owner's own cleanups", () => { + const log: string[] = []; + const dispose = createRoot(dispose => { + onCleanup(() => log.push("owner")); + createEffect( + () => 1, + () => () => boom() + ); + return dispose; + }); + flush(); + expect(dispose).toThrow("boom"); + expect(log).toEqual(["owner"]); + }); +}); + +describe("inside a flush", () => { + it("a branch switch runs every cleanup of the old frame, commits, then the flush throws", () => { + const log: string[] = []; + const seen: string[] = []; + const [show, setShow] = createSignal(true); + const dispose = createRoot(dispose => { + const view = createMemo(() => { + if (!show()) return "off"; + onCleanup(() => log.push("first")); + createRenderEffect( + () => 1, + () => () => log.push("effect cleanup") + ); + onCleanup(() => boom()); + return "on"; + }); + createRenderEffect(view, v => void seen.push(v)); + return dispose; + }); + flush(); + + setShow(false); + expect(() => flush()).toThrow("boom"); + expect(log).toEqual(["effect cleanup", "first"]); + // The flush completed before rethrowing: the new branch is on screen. + expect(seen).toEqual(["on", "off"]); + + // No latch, and nothing left half-applied: later flushes are clean. + expect(() => flush()).not.toThrow(); + setShow(true); + flush(); + expect(seen).toEqual(["on", "off", "on"]); + expect(dispose).toThrow("boom"); + expect(log).toEqual(["effect cleanup", "first", "effect cleanup", "first"]); + }); + + it("an error boundary's content moves on; the error surfaces from the flush", () => { + const seen: unknown[] = []; + const [show, setShow] = createSignal(true); + const dispose = createRoot(dispose => { + const out = createErrorBoundary( + () => { + const view = createMemo(() => { + if (!show()) return "off"; + onCleanup(() => boom()); + return "on"; + }); + return view(); + }, + () => "fallback" + ); + createRenderEffect(out, v => void seen.push(v)); + return dispose; + }); + flush(); + setShow(false); + expect(() => flush()).toThrow("boom"); + expect(seen).toEqual(["on", "off"]); + dispose(); + }); + + it("scheduling keeps working after the flush rethrew", async () => { + const [count, setCount] = createSignal(0); + const [show, setShow] = createSignal(true); + const seen: number[] = []; + const dispose = createRoot(dispose => { + createMemo(() => { + if (show()) onCleanup(() => boom()); + }); + createRenderEffect(count, v => void seen.push(v)); + return dispose; + }); + flush(); + setShow(false); + expect(() => flush()).toThrow("boom"); + + // The automatic (microtask) flush still runs. + setCount(1); + await Promise.resolve(); + expect(seen).toEqual([0, 1]); + dispose(); + }); + + it("a root disposed by an effect: the effect continues, the flush rethrows at its end", () => { + const log: string[] = []; + const [go, setGo] = createSignal(false); + const disposeOther = createRoot(dispose => { + onCleanup(() => log.push("other first")); + onCleanup(() => boom()); + return dispose; + }); + const dispose = createRoot(dispose => { + createEffect(go, v => { + if (!v) return; + disposeOther(); + log.push("after dispose"); + }); + return dispose; + }); + flush(); + setGo(true); + expect(() => flush()).toThrow("boom"); + expect(log).toEqual(["other first", "after dispose"]); + dispose(); + }); + + it("a lazy node released mid-walk does not rethrow before the walk is done", () => { + const log: string[] = []; + let lazy!: () => number; + const disposeSource = createRoot(dispose => { + lazy = createMemo( + () => { + onCleanup(() => log.push("lazy")); + return 1; + }, + { lazy: true } + ); + return dispose; + }); + const dispose = createRoot(dispose => { + onCleanup(() => log.push("root")); + // Disposed after the effect below (children go newest first)... + createRoot(() => { + onCleanup(() => log.push("child")); + }); + // ...and this one first, so its error is collected before the effect's + // teardown releases `lazy` through `unobserved`. + createRenderEffect(lazy, () => {}); + createRoot(() => { + onCleanup(() => boom()); + }); + return dispose; + }); + flush(); + + expect(dispose).toThrow("boom"); + expect(log).toEqual(["lazy", "child", "root"]); + disposeSource(); + }); + + it("a halt drops a collected cleanup error: a later dispose does not throw it", () => { + const [go, setGo] = createSignal(false); + const disposeOther = createRoot(dispose => { + onCleanup(() => boom("cleanup")); + return dispose; + }); + const dispose = createRoot(dispose => { + createEffect(go, v => { + if (v) disposeOther(); + }); + createEffect(go, v => { + if (v) boom("effect"); + }); + return dispose; + }); + flush(); + setGo(true); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(() => flush()).toThrow("effect"); + } finally { + error.mockRestore(); + } + const unrelated = createRoot(dispose => dispose); + expect(unrelated).not.toThrow(); + resetErrorHalt(); + dispose(); + }); +}); + +describe("unobserved hooks", () => { + it("a throwing hook does not stop the teardown around it", () => { + const log: string[] = []; + let source!: () => number; + const disposeSource = createRoot(dispose => { + source = createMemo(() => 1, { + lazy: true, + unobserved: () => { + log.push("hook"); + boom("hook"); + } + }); + return dispose; + }); + const dispose = createRoot(dispose => { + createRenderEffect(source, () => {}); + onCleanup(() => log.push("root")); + return dispose; + }); + flush(); + + expect(dispose).toThrow("hook"); + expect(log).toEqual(["hook", "root"]); + disposeSource(); + }); +}); diff --git a/packages/signals/tests/deferred-release-cleanup-throw.test.ts b/packages/signals/tests/deferred-release-cleanup-throw.test.ts index 3454f665f..5d23cc836 100644 --- a/packages/signals/tests/deferred-release-cleanup-throw.test.ts +++ b/packages/signals/tests/deferred-release-cleanup-throw.test.ts @@ -18,9 +18,9 @@ it.each([false, true])( const [version, setVersion] = createSignal(0); const disposePending = createRoot(dispose => { createMemo(() => { - version(); + const v = version(); onCleanup(() => { - if (throws) throw failure; + if (throws && v === 0) throw failure; }); return new Promise(() => {}); }); @@ -61,3 +61,92 @@ it.each([false, true])( } } ); + +// An `unobserved` hook that throws while the frame is dismantled is collected +// like a cleanup: the pass's release collector is still removed, so a later, +// unrelated release happens. +it("a throwing unobserved hook does not leave the release collector installed", () => { + const failure = new Error("hook failed"); + const [version, setVersion] = createSignal(0); + const disposePending = createRoot(dispose => { + const hooked = createMemo(() => 1, { + lazy: true, + unobserved: () => { + throw failure; + } + }); + createMemo(() => { + if (version() === 0) createMemo(() => hooked()); + return new Promise(() => {}); + }); + return dispose; + }); + let disposeOther: (() => void) | undefined; + try { + flush(); + setVersion(1); + expect(() => flush()).toThrow(failure); + + let cleaned = 0; + let stop!: () => void; + disposeOther = createRoot(dispose => { + const value = createMemo( + () => { + onCleanup(() => cleaned++); + return 1; + }, + { lazy: true } + ); + stop = createRoot(dispose => { + createRenderEffect(value, () => {}); + return dispose; + }); + return dispose; + }); + flush(); + stop(); + flush(); + expect(cleaned).toBe(1); + } finally { + disposeOther?.(); + disposePending(); + flush(); + } +}); + +// A lazy node the dismantled frame's children released before the throw is +// still released: the throw no longer skips the drain at the end of the pass +// (the teardown is nested in the pass, and the flush rethrows when it ends). +it("a cleanup throw still releases lazy nodes collected before it", () => { + const failure = new Error("cleanup failed"); + const [version, setVersion] = createSignal(0); + let cleaned = 0; + const dispose = createRoot(dispose => { + const lazy = createMemo( + () => { + onCleanup(() => cleaned++); + return 1; + }, + { lazy: true } + ); + createMemo(() => { + if (version() === 0) { + createMemo(() => lazy()); + onCleanup(() => { + throw failure; + }); + } + return new Promise(() => {}); + }); + return dispose; + }); + try { + flush(); + setVersion(1); + expect(() => flush()).toThrow(failure); + expect(cleaned).toBe(1); + } finally { + dispose(); + flush(); + } +}); diff --git a/packages/signals/tests/untracked-read-after-await.test.ts b/packages/signals/tests/untracked-read-after-await.test.ts index 97d6d7200..d6e5ba251 100644 --- a/packages/signals/tests/untracked-read-after-await.test.ts +++ b/packages/signals/tests/untracked-read-after-await.test.ts @@ -250,8 +250,8 @@ describe("UNTRACKED_READ_AFTER_AWAIT (dev)", () => { return 1; }, "thrower"); await settle(); - // The throw skipped the depth's exit. The first check to see the stale - // depth is muted and schedules the repair; the next continuation warns. + // The throwing cleanup is caught inside the teardown, so the depth is + // lowered on the spot: the very next continuation warns. const first = mount(async () => { await null; return x(); @@ -262,7 +262,10 @@ describe("UNTRACKED_READ_AFTER_AWAIT (dev)", () => { return x(); }, "second-after"); await settle(); - expect(stop().map(e => [e.ownerName, e.nodeName])).toEqual([["second-after", "x"]]); + expect(stop().map(e => [e.ownerName, e.nodeName])).toEqual([ + ["first-after", "x"], + ["second-after", "x"] + ]); dispose(); first.dispose(); second.dispose(); diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index e76426acc..d4f212af3 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -519,7 +519,8 @@ function unlinkOwner(node: SSROwner): void { * forward-only `_firstChild` -> `_nextSibling` chain, recursively disposing * each child with `self=true`, then runs the owner's own `_disposal` queue * in unwind order (later registrations first) and resets `_firstChild` / - * `_childCount`. + * `_childCount`. A cleanup that throws does not stop the walk: every cleanup + * runs, and the first error is rethrown once the walk is done (#4193). * * `self=false` keeps `owner` itself alive (its `_disposed` flag stays clear, * future `runWithOwner(owner, ...)` keeps working) but tears down its @@ -555,10 +556,18 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { return; } if (self) node._disposed = true; + // A cleanup that throws stops neither the rest of the tree nor this + // owner's other cleanups (#4193): the first error is rethrown once the + // walk is done — a child's walk rethrows its own, collected here. + let error: [unknown] | undefined; let child = node._firstChild; while (child) { const next = child._nextSibling; - disposeOwner(child as unknown as Owner, true); + try { + disposeOwner(child as unknown as Owner, true); + } catch (e) { + error ||= [e]; + } child = next; } node._firstChild = null; @@ -571,14 +580,16 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { // cleanup that re-enters this owner's disposal must find nothing to re-run. const d = node._disposal; node._disposal = null; + // Unwind order, mirroring the client `runDisposal` (#3572): later + // registrations run before earlier ones. if (d) { - if (Array.isArray(d)) { - // Unwind order, mirroring the client `runDisposal` (#3572): later - // registrations run before earlier ones. - for (let i = d.length - 1; i >= 0; i--) d[i](); - } else { - d(); - } + const many = Array.isArray(d); + for (let i = many ? d.length - 1 : 0; i >= 0; i--) + try { + (many ? d[i] : d)(); + } catch (e) { + error ||= [e]; + } } if (self) unlinkOwner(node); // Recycle the disposed owner. Skip the root case (`self=false`) and the @@ -588,6 +599,7 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { node.id = undefined; ownerPool.push(node); } + if (error) throw error[0]; } /** diff --git a/packages/solid/test/server/signals.spec.ts b/packages/solid/test/server/signals.spec.ts index 8114fc2ed..d521a519f 100644 --- a/packages/solid/test/server/signals.spec.ts +++ b/packages/solid/test/server/signals.spec.ts @@ -681,6 +681,33 @@ describe("Server owner tree", () => { expect(order).toEqual(["C", "B", "A"]); }); + test("a throwing cleanup does not stop the walk; the first error is rethrown after (#4193)", () => { + const order: string[] = []; + let dispose!: () => void; + let child!: Owner; + createRoot( + d => { + dispose = d; + onCleanup(() => order.push("A")); + onCleanup(() => { + throw new Error("owner threw"); + }); + createRoot(() => { + child = getOwner()!; + onCleanup(() => order.push("C")); + onCleanup(() => { + throw new Error("child threw"); + }); + }); + createRoot(() => onCleanup(() => order.push("D"))); + }, + { id: "test" } + ); + expect(dispose).toThrow("child threw"); + expect(order).toEqual(["D", "C", "A"]); + expect(isDisposed(child)).toBe(true); + }); + test("a cleanup that disposes its own root during a rerun reset runs once (#3601)", () => { // Twin of the client `runDisposal` ruling: `disposeOwner` detaches the // list before running it. A rerun reset (`self=false`, the server's diff --git a/packages/web/test/server/stream-ambient-error-hook-4193.spec.tsx b/packages/web/test/server/stream-ambient-error-hook-4193.spec.tsx index c32d5376c..bf3a18110 100644 --- a/packages/web/test/server/stream-ambient-error-hook-4193.spec.tsx +++ b/packages/web/test/server/stream-ambient-error-hook-4193.spec.tsx @@ -12,10 +12,9 @@ * - an `onCleanup` that throws in the final dispose after a normal * completion: only `abandon()` contained a throwing dispose (#4029). * - * Not pinned here: whether the cleanups registered before a throwing one - * still run. The owner teardown stops at a throw on the server as it does on - * the client (`disposeOwner` / `runDisposal`); that is owner semantics, not - * this containment. + * The cleanups registered before a throwing one still run: the owner + * teardown (`disposeOwner`) runs every cleanup and rethrows the first error + * once it is done, which is what reaches the hook. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -198,6 +197,7 @@ describe("renderToStream: an onCleanup that throws in the final dispose (#4193)" expect(completed).toBe(true); expect(kinds()).toEqual(["render/failed"]); expect((heard[0].error as Error).message).toBe("cleanup threw"); + expect(ran).toEqual(["first"]); }); test("reaches the ambient hook as render/failed when piped", async () => { @@ -210,6 +210,7 @@ describe("renderToStream: an onCleanup that throws in the final dispose (#4193)" expect(escaped).toEqual([]); expect(result!.state).toBe("settled"); expect(kinds()).toEqual(["render/failed"]); + expect(ran).toEqual(["first"]); }); test("the render's own onError hears it ahead of the ambient hook", async () => { diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 61fc4c833..4d4107a88 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,30 +1,30 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": { - "cap": "7.39 KB", - "minified": 19991 + "cap": "7.44 KB", + "minified": 20137 }, "app: render + one signal (the simple-app floor)": { - "cap": "9.70 KB", - "minified": 26881 + "cap": "9.75 KB", + "minified": 27029 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { - "cap": "18.36 KB", - "minified": 53454 + "cap": "18.38 KB", + "minified": 53598 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.20 KB", - "minified": 101429 + "cap": "33.27 KB", + "minified": 101526 }, "page: live server components (base + live/GET + action + isPending/latest)": { "cap": "37.53 KB", - "minified": 115031 + "minified": 115011 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", "minified": 3324 }, "server: renderToString (the server-render floor)": { - "cap": "20.59 KB", - "minified": 72154 + "cap": "20.65 KB", + "minified": 72189 } } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 3794728de..b1e189c94 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -598,6 +598,7 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 7.35 KB unchanged, recorded minified 19,934 -> 19,962 B (+28 B), measured in CI at 7,338 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 7.35 -> 7.39 KB, recorded minified 19,962 -> 20,021 B, measured locally (linux) at 7,374 B brotli / 20,021 B minified (+59 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 7.39 KB unchanged, recorded minified 20,021 -> 19,991 B (-30 B), measured in CI at 7,350 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 7.39 KB -> 7.44 KB, recorded minified 19,991 -> 20,137 B (+146 B), measured in CI at 7,426 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], capMinified: floorMinified["signals: core floor (createSignal/Memo/Effect/Root/flush)"], alias @@ -1093,8 +1094,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 14.90 KB -> 14.94 KB, recorded minified 45,089 -> 45,115 B (+26 B), measured in CI at 14,927 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 14.94 -> 14.95 KB, recorded minified 45,115 -> 45,175 B, measured locally (linux) at 14,932 B brotli / 45,175 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 14.95 KB unchanged, recorded minified 45,175 -> 45,145 B (-30 B), measured in CI at 14,914 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "14.95 KB", - capMinified: 45145, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 14.95 KB -> 15.01 KB, recorded minified 45,145 -> 45,286 B (+141 B), measured in CI at 14,997 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "15.01 KB", + capMinified: 45286, alias }, { @@ -1402,8 +1404,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 10.12 KB -> 10.14 KB, recorded minified 28,356 -> 28,383 B (+27 B), measured in CI at 10,121 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 10.14 -> 10.16 KB, recorded minified 28,383 -> 28,443 B, measured locally (linux) at 10,144 B brotli / 28,443 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 10.16 KB -> 10.19 KB, recorded minified 28,443 -> 28,463 B (+20 B), measured in CI at 10,173 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "10.19 KB", - capMinified: 28463, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 10.19 KB -> 10.25 KB, recorded minified 28,463 -> 28,625 B (+162 B), measured in CI at 10,236 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "10.25 KB", + capMinified: 28625, alias }, { @@ -1438,8 +1441,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 7.95 KB -> 7.96 KB, recorded minified 21,745 -> 21,773 B (+28 B), measured in CI at 7,941 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 7.96 -> 7.98 KB, recorded minified 21,773 -> 21,832 B, measured locally (linux) at 7,969 B brotli / 21,832 B minified (+59 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 7.98 KB unchanged, recorded minified 21,832 -> 21,802 B (-30 B), measured in CI at 7,960 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "7.98 KB", - capMinified: 21802 + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 7.98 KB -> 8.05 KB, recorded minified 21,802 -> 21,946 B (+144 B), measured in CI at 8,036 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "8.05 KB", + capMinified: 21946 }, { name: "app: render + one signal (the simple-app floor)", @@ -1692,6 +1696,7 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 9.66 KB -> 9.67 KB, recorded minified 26,824 -> 26,852 B (+28 B), measured in CI at 9,651 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 9.67 -> 9.70 KB, recorded minified 26,852 -> 26,911 B, measured locally (linux) at 9,681 B brotli / 26,911 B minified (+59 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 9.70 KB unchanged, recorded minified 26,911 -> 26,881 B (-30 B), measured in CI at 9,681 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 9.70 KB -> 9.75 KB, recorded minified 26,881 -> 27,029 B (+148 B), measured in CI at 9,738 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["app: render + one signal (the simple-app floor)"], capMinified: floorMinified["app: render + one signal (the simple-app floor)"], alias @@ -2084,6 +2089,7 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 18.29 KB -> 18.32 KB, recorded minified 53,401 -> 53,428 B (+27 B), measured in CI at 18,308 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 18.32 KB (unchanged), recorded minified 53,428 -> 53,488 B, measured locally (linux) at 18,309 B brotli / 53,488 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 18.32 KB -> 18.36 KB, recorded minified 53,488 -> 53,454 B (-34 B), measured in CI at 18,342 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 18.36 KB -> 18.38 KB, recorded minified 53,454 -> 53,598 B (+144 B), measured in CI at 18,368 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], capMinified: floorMinified["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], alias @@ -2635,8 +2641,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 30.30 KB unchanged, recorded minified 94,999 -> 95,025 B (+26 B), measured in CI at 30,267 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 30.30 -> 30.32 KB, recorded minified 95,025 -> 95,085 B, measured locally (linux) at 30,308 B brotli / 95,085 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 30.32 KB -> 30.41 KB, recorded minified 95,085 -> 95,109 B (+24 B), measured in CI at 30,394 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "30.41 KB", - capMinified: 95109, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 30.41 KB -> 30.42 KB, recorded minified 95,109 -> 95,268 B (+159 B), measured in CI at 30,403 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "30.42 KB", + capMinified: 95268, alias }, { @@ -2925,8 +2932,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 13.24 KB unchanged, recorded minified 37,060 -> 37,087 B (+27 B), measured in CI at 13,217 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 13.24 -> 13.28 KB, recorded minified 37,087 -> 37,147 B, measured locally (linux) at 13,264 B brotli / 37,147 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 13.28 KB unchanged, recorded minified 37,147 -> 37,113 B (-34 B), measured in CI at 13,249 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "13.28 KB", - capMinified: 37113, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 13.28 KB -> 13.33 KB, recorded minified 37,113 -> 37,252 B (+139 B), measured in CI at 13,312 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "13.33 KB", + capMinified: 37252, alias }, { @@ -3268,8 +3276,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 14.99 KB -> 15.00 KB, recorded minified 42,019 -> 42,046 B (+27 B), measured in CI at 14,988 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 15.00 -> 15.03 KB, recorded minified 42,046 -> 42,106 B, measured locally (linux) at 15,016 B brotli / 42,106 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 15.03 KB -> 15.04 KB, recorded minified 42,106 -> 42,072 B (-34 B), measured in CI at 15,024 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "15.04 KB", - capMinified: 42072, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 15.04 KB -> 15.08 KB, recorded minified 42,072 -> 42,209 B (+137 B), measured in CI at 15,062 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "15.08 KB", + capMinified: 42209, alias: observeAlias }, { @@ -3762,8 +3771,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 29.59 KB unchanged, recorded minified 88,339 -> 88,365 B (+26 B), measured in CI at 29,535 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 29.59 -> 29.63 KB, recorded minified 88,365 -> 88,425 B, measured locally (linux) at 29,612 B brotli / 88,425 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 29.63 KB unchanged, recorded minified 88,425 -> 88,391 B (-34 B), measured in CI at 29,577 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "29.63 KB", - capMinified: 88391, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 29.63 KB -> 29.64 KB, recorded minified 88,391 -> 88,527 B (+136 B), measured in CI at 29,622 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "29.64 KB", + capMinified: 88527, alias: observeAlias }, // Compiled-template scenarios (2026-10-05): the four `app:` fixtures above @@ -3852,8 +3862,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 9.86 KB unchanged, recorded minified 27,356 -> 27,383 B (+27 B), measured in CI at 9,846 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 9.86 -> 9.89 KB, recorded minified 27,383 -> 27,442 B, measured locally (linux) at 9,879 B brotli / 27,442 B minified (+59 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 9.89 KB unchanged, recorded minified 27,442 -> 27,412 B (-30 B), measured in CI at 9,873 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "9.89 KB", - capMinified: 27412, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 9.89 KB -> 9.93 KB, recorded minified 27,412 -> 27,560 B (+148 B), measured in CI at 9,919 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "9.93 KB", + capMinified: 27560, alias }, { @@ -3975,8 +3986,9 @@ module.exports = [ // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 26.10 KB (unchanged), recorded minified 81,112 -> 81,172 B, measured locally (linux) at 26,087 B brotli / 81,172 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 26.10 KB -> 26.11 KB, recorded minified 81,172 -> 81,138 B (-34 B), measured in CI at 26,092 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Re-recorded on next @ 0222d684 (2026-10-21, accepted by the maintainer): 26.11 KB -> 26.14 KB, recorded minified 81,138 -> 81,159 B (+21 B), measured in CI at 26,123 B brotli. #4188 (reaction arms captured before tracking reads), #4207 (#4203, unread pending memo under a verdict mount) and #4176 (deferred releases restored after a throwing cleanup) each passed alone and together put next 1 B past the 20 B minified allowance. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "26.14 KB", - capMinified: 81159, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 26.14 KB -> 26.23 KB, recorded minified 81,159 -> 81,283 B (+124 B), measured in CI at 26,214 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "26.23 KB", + capMinified: 81283, alias }, { @@ -4131,8 +4143,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 32.13 KB unchanged, recorded minified 101,651 -> 101,677 B (+26 B), measured in CI at 32,067 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 32.13 KB (unchanged), recorded minified 101,677 -> 101,737 B, measured locally (linux) at 32,091 B brotli / 101,737 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 32.13 KB -> 32.20 KB, recorded minified 101,737 -> 101,703 B (-34 B), measured in CI at 32,182 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 32.20 KB (unchanged), recorded minified 101,703 -> 101,848 B (+145 B), measured in CI at 32,174 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: "32.20 KB", - capMinified: 101703, + capMinified: 101848, alias }, { @@ -5053,6 +5066,7 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 33.16 KB -> 33.18 KB, recorded minified 101,352 -> 101,384 B (+32 B), measured in CI at 33,166 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 33.18 -> 33.20 KB, recorded minified 101,384 -> 101,444 B, measured locally (linux) at 33,190 B brotli / 101,444 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 33.20 KB unchanged, recorded minified 101,444 -> 101,429 B (-15 B), measured in CI at 33,170 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 33.20 KB -> 33.27 KB, recorded minified 101,429 -> 101,526 B (+97 B), measured in CI at 33,255 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -5393,6 +5407,7 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 37.46 KB unchanged, recorded minified 114,901 -> 114,922 B (+21 B), measured in CI at 37,410 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 37.46 -> 37.48 KB, recorded minified 114,922 -> 114,982 B, measured locally (linux) at 37,467 B brotli / 114,982 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 37.48 KB -> 37.53 KB, recorded minified 114,982 -> 115,031 B (+49 B), measured in CI at 37,516 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 37.53 KB (unchanged), recorded minified 115,031 -> 115,011 B (-20 B), measured in CI at 37,512 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5551,8 +5566,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 35.19 KB -> 35.23 KB, recorded minified 107,701 -> 107,725 B (+24 B), measured in CI at 35,212 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 35.23 KB (unchanged), recorded minified 107,725 -> 107,785 B, measured locally (linux) at 35,212 B brotli / 107,785 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 35.23 KB -> 35.30 KB, recorded minified 107,785 -> 107,770 B (-15 B), measured in CI at 35,285 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 35.30 KB (unchanged), recorded minified 107,770 -> 107,876 B (+106 B), measured in CI at 35,271 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: "35.30 KB", - capMinified: 107770, + capMinified: 107876, alias: pageAlias }, { @@ -5680,8 +5696,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 41.31 KB unchanged, recorded minified 122,880 -> 122,907 B (+27 B), measured in CI at 41,283 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 41.31 KB (unchanged), recorded minified 122,907 -> 122,967 B, measured locally (linux) at 41,300 B brotli / 122,967 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): 41.31 KB -> 41.42 KB, recorded minified 122,967 -> 123,015 B (+48 B), measured in CI at 41,410 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 41.42 KB (unchanged), recorded minified 123,015 -> 123,010 B (-5 B), measured in CI at 41,403 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: "41.42 KB", - capMinified: 123015, + capMinified: 123010, alias: pageAlias }, { @@ -5879,8 +5896,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): cap 40.71 KB unchanged, recorded minified 125,181 -> 125,207 B (+26 B), measured in CI at 40,645 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 40.71 -> 40.72 KB, recorded minified 125,207 -> 125,267 B, measured locally (linux) at 40,706 B brotli / 125,267 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 40.72 KB unchanged, recorded minified 125,267 -> 125,252 B (-15 B), measured in CI at 40,652 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "40.72 KB", - capMinified: 125252, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 40.72 KB -> 40.78 KB, recorded minified 125,252 -> 125,364 B (+112 B), measured in CI at 40,765 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "40.78 KB", + capMinified: 125364, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -6054,8 +6072,9 @@ module.exports = [ // Every comparator call guarded, default comparators on a fast path (#4111, 2026-10-20, over next @ 6371b910): 46.86 KB -> 46.90 KB, recorded minified 140,165 -> 140,198 B (+33 B), measured in CI at 46,884 B brotli. `isSame` is the one guarded comparator call (a NotReady read is a change, any other throw goes to the node's boundary, A3), with `isEqual` on a fast path so the propagation benches keep next's speed. Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. // Writable memo write clears its error without a torn read (#4110, 2026-10-20, over next @ 7014aca4, accepted by the maintainer): 46.90 -> 46.91 KB, recorded minified 140,198 -> 140,258 B, measured locally (linux) at 46,896 B brotli / 140,258 B minified (+60 B minified over the recorded size), which includes #4163's +9 B merged without re-recording: a write to an errored writable memo recovers it at commit and keeps the error shown until then, and clears an error a pass staged under the same hold. Correctness; matches CI's numbers. // Verdict-mount lane provenance for nested first passes (#4166, 2026-10-20, over next @ 5a4b2e85): cap 46.91 KB unchanged, recorded minified 140,258 -> 140,296 B (+38 B), measured in CI at 46,884 B brotli. A module-local laneRoot carries the mount's lane through nested first passes, gated on the lane engine, so bundles without it shrink. Size-Exception accepted by the maintainer (2026-10-20). Cap at measured + 10 B rounded up to 0.01 KB, never lowered here; recorded minified = measured. - limit: "46.91 KB", - capMinified: 140296, + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 46.91 KB -> 46.96 KB, recorded minified 140,296 -> 140,292 B (-4 B), measured in CI at 46,942 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. + limit: "46.96 KB", + capMinified: 140292, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -6176,6 +6195,7 @@ module.exports = [ // 01270641): 20.65 KB -> 20.59 KB, recorded minified 72,466 -> 72,154 B; // measured locally (linux) at 20,573 B (72,154 B minified). Lower only: cap // at measured + 10 B rounded up to 0.01 KB; recorded minified never raised. + // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 20.59 KB -> 20.65 KB, recorded minified 72,154 -> 72,189 B (+35 B), measured in CI at 20,632 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. limit: floorCaps["server: renderToString (the server-render floor)"], capMinified: floorMinified["server: renderToString (the server-render floor)"], alias: serverAlias,