From 323f9adb58f14427767e3b2b743a45a0d7dd2c1c Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Sun, 13 Sep 2026 08:18:39 -0500 Subject: [PATCH 1/3] fix(signals): defer a transaction-owned node's committed children to its commit (#3404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A render effect parked on a pending source is stamped with the transaction without recomputing, so its owned children (nested render effects, memos, onCleanup registrations) still belong to the committed frame. When the source landed, recompute saw the stamp and disposed them on the spot, running their cleanups mid-hold — a Portal under a keyed Show disappeared when the first hop of an async chain landed and reappeared only when the whole chain settled. Children are now deferred as zombies until the node commits unless CONFIG_HELD_CHILDREN says the pass that built them never committed (a staged value, a pending window, a run under a held transaction), in which case no frame ever showed them and they die immediately. The bit is set at recompute's tail and cleared by commitPendingNode. A transaction-owned effect recomputed mainline (contested, #3322) publishes directly and releases its zombies itself rather than letting two generations render until the transaction commits. Co-Authored-By: Claude Fable 5.1 --- .../fix-held-transition-child-disposal.md | 5 + .../signals/docs/INTERNALS-ASYNC-STATE.md | 1 + packages/signals/src/core/constants.ts | 13 ++ packages/signals/src/core/core.ts | 37 +++- packages/signals/src/core/scheduler.ts | 3 + ...nested-render-effect-async-cleanup.test.ts | 202 ++++++++++++++++++ packages/signals/tests/treeshake.test.ts | 8 +- ...-render-effect-cleanup-issue-3404.spec.tsx | 97 +++++++++ 8 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-held-transition-child-disposal.md create mode 100644 packages/signals/tests/nested-render-effect-async-cleanup.test.ts create mode 100644 packages/web/test/nested-render-effect-cleanup-issue-3404.spec.tsx diff --git a/.changeset/fix-held-transition-child-disposal.md b/.changeset/fix-held-transition-child-disposal.md new file mode 100644 index 000000000..fe9a1d488 --- /dev/null +++ b/.changeset/fix-held-transition-child-disposal.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A node recomputed under a held transaction no longer tears down the committed frame's children immediately. Status propagation stamps a parked dependent with the transaction without recomputing it, so its owned children (nested render effects, memos, `onCleanup` registrations) still belong to what is on screen; when the pending source landed, the recompute disposed them on the spot and their cleanups ran mid-hold, before the transaction's atomic reveal (#3404). Those children are now deferred as zombies until the node commits, matching the plain-flush path. Children built by a recompute that never committed (a staged value, a pending window, a run under the transaction) are still disposed immediately on the next re-run — no frame ever showed them. diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index 8592ba14b..5e181a6a3 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -95,6 +95,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `initTransition` ends by scheduling a flush: the ambient window is one flush by definition, but parking is flush-driven, so a transaction opened with no writes (an action that only awaits) would otherwise leave `activeTransition` and the adopted batch armed across the async gap, capturing the next unrelated work to arrive — the A26-rejected behavior (#3141). - `_asyncReporters: Map>` — which computeds are blocked on which async sources. **Entries open only from `GlobalQueue.notify` during render-effect status notification** `[ruled — async-registration-invariants rule]`. One reporter may join an entry that already exists from elsewhere: a stale reader served a pending node's committed value by the reveal carve-out (`heldFromStale`, §3 below) joins that node's entry (#3374) — it observes the flight, so it holds the transaction on it the way the reader that opened the entry did, and dies with disposal the same way (`reporterBlocksSource`: the read linked the node as a dep). - `_pendingNodes` — nodes whose `_pendingValue` commits when the transition completes (`commitPendingNodes` → `commitPendingNode`). +- Held children (#3404): a node's owned children (nested effects, memos, `onCleanup` registrations) belong to the frame that committed them. `recompute` defers the previous pass's children as zombies (`_pendingFirstChild` / `_pendingDisposal`, rendering mainline until `commitPendingNode` disposes them) unless `CONFIG_HELD_CHILDREN` is set — the pass that built them never committed (a staged value, a pending window, a run under a held transaction), so no frame ever showed them and they die on the spot. Set at recompute's tail whenever the pass's result waits on a commit, cleared by `commitPendingNode`. A `_transition` stamp alone says nothing about the children: status propagation stamps a parked dependent without recomputing it, so its children are still the committed frame's, and disposing them when the source lands ran their cleanups mid-hold. Exception: a transaction-owned effect recomputed mainline (contested, #3322) publishes directly, so that pass's children are the frame's and it releases its zombies itself — its commit rides the transaction, not the flush. - `_optimisticNodes` — nodes whose override reverts at completion (`resolveOptimisticNodes`). - Incomplete-transition flush stashes queues (`stashQueues`) and continues with a fresh view; completion restores them, commits pending, reverts optimistic, replays `_gatedSubs`, cleans lanes. - `_contested` — effects whose single value slot was written under this transaction and then overwritten by another live transaction or by mainline (#3322). Effects are not shared state, so a shared effect never merges transactions (memos do, via their `_transition` stamp); instead `Effect._valueTransition` records which view produced `_value`, `recompute`, when that owner changes, registers the effect on every owed live transaction, and `finalizePureQueue` re-dirties them **before** its heap run so the re-derive and the effect phase land in the same pass — the other view's value is never published. Exception: a settle that reverts optimism (a non-empty `_optimisticNodes`) re-dirties them **after** `_resolveOptimistic`, with the gated replay — between `commitPendingNodes` and the revert the truth is committed but the overrides still display, and a re-derive there composes the two (the #3164 tear; the reveal wake sits post-revert for the same reason). The slot meanwhile holds the frame already on screen, so nothing new is published early. Rules that fall out: a stale (render) reader with no transaction active is mainline and sees a foreign transaction's staged signal as committed (`read`'s fast path and `readNodeFast` apply `stale && el._transition !== null`, matching the slow path); a value computed mainline needs no protection (mainline publishes what it computes, and a transaction whose writes never touched the effect finds it still correct at commit). diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 09fef26a6..0747839ab 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -134,6 +134,19 @@ export const CONFIG_SLOT_NODE = 1 << 18; * write (a new override re-masks) and by the revert. */ export const CONFIG_OVERRIDE_SUPERSEDED = 1 << 19; +/** HELD children (#3404): this node's `_firstChild` chain (and `_disposal` + * list) was built by a recompute whose result has not committed — a staged + * value, a pending window, or a run under a held transaction. A later + * recompute may tear those children down immediately: nothing observable + * was ever built on them. Unset, the children belong to the committed frame + * and a recompute defers them as zombies (`_pendingFirstChild`) until this + * node commits — regardless of whether the recompute runs under a + * transaction. A parked node (status propagation stamps `_transition` + * without recomputing) recomputed when its source lands otherwise disposed + * its committed children mid-hold, running their cleanups before the + * transaction's atomic reveal. Cleared by `commitPendingNode`. */ +export const CONFIG_HELD_CHILDREN = 1 << 20; + /** In-flight async node whose inputs were PUBLISHED while it was pending: a * batch or transaction committed with the node still `STATUS_PENDING` (an * unobserved flight, #3305), so the inputs are on screen and the node's diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 0567cc92c..b9c8397f9 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -19,6 +19,7 @@ import { CONFIG_FRESH_READ, CONFIG_IN_SNAPSHOT_SCOPE, CONFIG_HAS_COMPANIONS, + CONFIG_HELD_CHILDREN, CONFIG_HAS_LANE, CONFIG_HAS_SNAPSHOT, CONFIG_INPUTS_PUBLISHED, @@ -240,8 +241,14 @@ export function recompute(el: Computed, create: boolean = false): void { // work that replaced it. Idempotent with the cleanup-channel close. releaseFlightTeardown(el); } - // Tracked effects run after finalizePureQueue, so dispose immediately instead of deferring - if (el._transition || isEffect === EFFECT_TRACKED) disposeChildren(el); + // Tracked effects run after finalizePureQueue, so dispose immediately + // instead of deferring. Children built by an uncommitted recompute + // (CONFIG_HELD_CHILDREN) die immediately too: no frame ever showed them. + // Everything else is the committed frame's and is deferred as zombies + // until this node's commit — a transaction-owned node included (#3404): + // a parked node's children predate the hold, and tearing them down when + // the source lands ran cleanups before the transaction's atomic reveal. + if (isEffect === EFFECT_TRACKED || el._config & CONFIG_HELD_CHILDREN) disposeChildren(el); else if (el._firstChild !== null || el._disposal !== null) { markDisposal(el); const x = ext(el); @@ -649,10 +656,28 @@ export function recompute(el: Computed, create: boolean = false): void { // under the override (A17). Revert no longer commits anything, so an // unqueued covered hold would leak (INV-7) once the revert clears // _transition. - needsPendingCommit && - (!create || el._statusFlags & STATUS_PENDING) && - (!el._transition || hasOverride) && - queuePendingNode(el); + // + // While a pass's result waits on a commit, its children (and `_disposal`) + // wait with it, and a re-run may tear them down immediately + // (CONFIG_HELD_CHILDREN, #3404). One exception: a transaction-owned effect + // recomputed mainline (contested, #3322) published its value directly — a + // `_pendingValue` left from an earlier held pass is that transaction's, + // not this one's — so this pass's children are the frame's, and any + // zombies deferred at the top are superseded on that same frame. Its + // commit rides the transaction, not this flush: release them here rather + // than let two generations render at once. + let held = needsPendingCommit && (!create || (el._statusFlags & STATUS_PENDING) !== 0); + if (held && (!el._transition || hasOverride)) queuePendingNode(el); + else if ( + held && + activeTransition === null && + !(el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) + ) { + held = false; + disposeChildren(el, false, true); + } + if (held) el._config |= CONFIG_HELD_CHILDREN; + else el._config &= ~CONFIG_HELD_CHILDREN; el._transition && isEffect && activeTransition !== el._transition && diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 5326ce662..d47d0d3ae 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -11,6 +11,7 @@ import { REACTIVE_DISPOSED, REACTIVE_IN_HEAP, CONFIG_HAS_COMPANIONS, + CONFIG_HELD_CHILDREN, CONFIG_HAS_LANE, CONFIG_HAS_SNAPSHOT, CONFIG_INPUTS_PUBLISHED, @@ -1084,6 +1085,8 @@ function commitPendingNode(n: Signal): void { // store to an always-present computed slot. c._loading = false; c._flags! &= ~REACTIVE_MANUAL_WRITE; + // The children this commit publishes are the frame's now (#3404). + c._config! &= ~CONFIG_HELD_CHILDREN; if (!(c._statusFlags! & STATUS_PENDING)) c._statusFlags! &= ~STATUS_UNINITIALIZED; // A flight this commit leaves in the air (unobserved, or observed only by // a boundary) now has PUBLISHED inputs: its committed value is stale diff --git a/packages/signals/tests/nested-render-effect-async-cleanup.test.ts b/packages/signals/tests/nested-render-effect-async-cleanup.test.ts new file mode 100644 index 000000000..c35e3e5a4 --- /dev/null +++ b/packages/signals/tests/nested-render-effect-async-cleanup.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + onCleanup +} from "../src/index.js"; + +// Manual clock (see async-chain-supersession.test.ts). +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} + +describe("#3404 nested render effect reading a downstream async value", () => { + it("does not clean up the inner effect until the whole chain has settled", async () => { + reset(); + const log: string[] = []; + let setA!: (fn: (v: number) => number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + setA = sa; + const b = createMemo(() => delay(500, a())); + const c = createMemo(() => delay(1000, b())); + createLoadingBoundary( + () => { + createRenderEffect( + () => { + b(); + createRenderEffect(c, v => { + log.push(`run ${v}@${now}`); + return () => log.push(`cleanup ${v}@${now}`); + }); + }, + () => {} + ); + }, + () => {} + ); + }); + flush(); + await advanceTo(2000); + expect(log).toEqual(["run 1@1500"]); + + setA(x => x + 1); + flush(); + await advanceTo(2400); + expect(log).toEqual(["run 1@1500"]); + // b lands at 2500 but c is still in flight until 3500: nothing observable. + await advanceTo(3000); + expect(log).toEqual(["run 1@1500"]); + await advanceTo(4000); + expect(log).toEqual(["run 1@1500", "cleanup 1@3500", "run 2@3500"]); + }); + + it("a memo's onCleanup registrations wait for the commit the same way", async () => { + reset(); + const log: string[] = []; + let setA!: (fn: (v: number) => number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + setA = sa; + const b = createMemo(() => delay(500, a())); + const c = createMemo(() => delay(1000, b())); + createLoadingBoundary( + () => { + const outer = createMemo(() => { + const v = b(); + onCleanup(() => log.push(`cleanup ${v}@${now}`)); + return v; + }); + createRenderEffect( + () => [outer(), c()], + ([o, cc]) => { + log.push(`run ${o}/${cc}@${now}`); + } + ); + }, + () => {} + ); + }); + flush(); + await advanceTo(2000); + expect(log).toEqual(["run 1/1@1500"]); + + setA(x => x + 1); + flush(); + await advanceTo(3000); + expect(log).toEqual(["run 1/1@1500"]); + await advanceTo(4000); + expect(log).toEqual(["run 1/1@1500", "cleanup 1@3500", "run 2/2@3500"]); + }); + + it("children built under the hold are torn down on the next held re-run, the frame's at commit", async () => { + reset(); + const log: string[] = []; + let setA!: (fn: (v: number) => number) => void; + let setTick!: (fn: (v: number) => number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + const [tick, st] = createSignal(0); + setA = sa; + setTick = st; + const b = createMemo(() => delay(500, a())); + const c = createMemo(() => delay(1000, b())); + createLoadingBoundary( + () => { + createRenderEffect( + () => { + const v = b(); + const t = tick(); + createRenderEffect(c, cv => { + log.push(`run ${v}.${t}/${cv}@${now}`); + return () => log.push(`cleanup ${v}.${t}/${cv}@${now}`); + }); + }, + () => {} + ); + }, + () => {} + ); + }); + flush(); + await advanceTo(2000); + expect(log).toEqual(["run 1.0/1@1500"]); + + setA(x => x + 1); + flush(); + // b lands at 2500: the outer effect re-runs under the hold, building an + // inner effect that never runs (c is in flight). A sync write while the + // hold is open re-runs it mainline (contested, #3322): the held inner + // effect dies silently, the frame's is replaced on the spot, and the + // transaction's re-derive builds a fresh held one for the reveal. + await advanceTo(2600); + setTick(t => t + 1); + flush(); + await advanceTo(3000); + expect(log).toEqual(["run 1.0/1@1500", "run 1.1/1@2600", "cleanup 1.0/1@2600"]); + await advanceTo(4000); + expect(log).toEqual([ + "run 1.0/1@1500", + "run 1.1/1@2600", + "cleanup 1.0/1@2600", + "cleanup 1.1/1@3500", + "run 2.1/2@3500" + ]); + }); + + it("a plain sync re-run still releases the previous children at its own commit", () => { + const log: string[] = []; + let setA!: (fn: (v: number) => number) => void; + createRoot(() => { + const [a, sa] = createSignal(1); + setA = sa; + createRenderEffect( + () => { + const v = a(); + createRenderEffect( + () => v, + cv => { + log.push(`run ${cv}`); + return () => log.push(`cleanup ${cv}`); + } + ); + }, + () => {} + ); + }); + flush(); + expect(log).toEqual(["run 1"]); + setA(x => x + 1); + flush(); + expect(log).toEqual(["run 1", "run 2", "cleanup 1"]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 4b0f23619..5a2b1c791 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -252,7 +252,13 @@ describe("pay-for-use tree-shaking (#2883)", () => { // (22,915 → 22,973). // Companion lane parented (#3379): `notifyStatus` assigns the node's lane // before poking its companions — a reorder, -4 B (22,973 → 22,969). - expect(minifiedBytes).toBeLessThan(23_050); + // Held children (#3404): recompute defers a node's children as zombies + // unless the pass that built them never committed (CONFIG_HELD_CHILDREN, + // set at recompute's tail, cleared by commitPendingNode) — a + // transaction-owned node's committed children previously died on the + // spot — and a contested effect's mainline pass releases its zombies + // itself. +102 B (22,969 → 23,071). + expect(minifiedBytes).toBeLessThan(23_150); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/packages/web/test/nested-render-effect-cleanup-issue-3404.spec.tsx b/packages/web/test/nested-render-effect-cleanup-issue-3404.spec.tsx new file mode 100644 index 000000000..ad5bd9e43 --- /dev/null +++ b/packages/web/test/nested-render-effect-cleanup-issue-3404.spec.tsx @@ -0,0 +1,97 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +import { expect, test } from "vitest"; +import { createMemo, createRenderEffect, createSignal, flush, Loading, Show } from "solid-js"; +import { Portal, render } from "../src/index.js"; + +// Manual clock: async memos resolve when the clock passes their due time, in +// due-time order, with a full settle between landings. +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} + +test("a nested render effect reading a downstream async value is not cleaned up mid-hold (#3404)", async () => { + now = 0; + timers = []; + const root = document.createElement("div"); + const target = document.createElement("div"); + const log: string[] = []; + let bump!: () => void; + + const dispose = render( + () => ( + + {(() => { + const [a, setA] = createSignal(1); + bump = () => setA(x => x + 1); + const b = createMemo(() => delay(500, a())); + const c = createMemo(() => delay(1000, b())); + + createRenderEffect( + () => { + b(); + createRenderEffect(c, v => { + log.push(`run ${v}@${now}`); + return () => log.push(`cleanup ${v}@${now}`); + }); + }, + () => {} + ); + + return ( + + + +
{c()}
+
+
+
+ ); + })()} +
+ ), + root + ); + + await advanceTo(2000); + expect(target.innerHTML).toBe("
1
"); + expect(log).toEqual(["run 1@1500"]); + + bump(); + flush(); + // b lands at 2500 while c is still in flight until 3500: the portal must + // keep showing the previous frame until the whole update can reveal. + await advanceTo(3000); + expect(target.innerHTML).toBe("
1
"); + expect(log).toEqual(["run 1@1500"]); + + await advanceTo(4000); + expect(target.innerHTML).toBe("
2
"); + expect(log).toEqual(["run 1@1500", "cleanup 1@3500", "run 2@3500"]); + + dispose(); +}); From 988b40b4e5fcd99d1c84c24349183017d89bc5c0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 13 Sep 2026 23:33:39 -0700 Subject: [PATCH 2/3] chore(size): raise the createStore, store-families and observe-tier caps for #3404's held-children bit (+2/+14/+12 B) Co-authored-by: Cursor --- scripts/size/.size-limit.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 3fb9ef302..d83326349 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -425,7 +425,10 @@ module.exports = [ // Companion lane parented (#3379, 2026-09-12): 15.50 -> 15.55 KB, measured // at 15522 B against 15485 (+37 brotli on a -4 B minified reorder — the // statement moved across a block boundary; noise, not weight). - limit: "15.55 KB", + // Held children (#3404, 2026-09-13): 15.55 -> 15.60 KB, measured at + // 15552 B on the merge with `next` — CONFIG_HELD_CHILDREN set/cleared + // around recompute and commitPendingNode. + limit: "15.60 KB", modifyEsbuildConfig }, { @@ -859,7 +862,10 @@ module.exports = [ // 28268 (+32 — the core seams and the collecting boundary). // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 28.35 -> 28.40 KB, // measured at 28380 B against 28300 (+80); see the core floor note. - limit: "28.40 KB", + // Held children (#3404, 2026-09-13): 28.40 -> 28.45 KB, measured at + // 28414 B on the merge with `next`; see the createStore note. (#3412's + // ownership save/restore measures +8 B on the same entry.) + limit: "28.45 KB", modifyEsbuildConfig }, { @@ -1007,7 +1013,9 @@ module.exports = [ // provider). One literal property on the observe object, inert on the // client by design. Measured at 15501 B against 15485 without the slot // on the same `next` (+16), within the 15.55 KB cap. Observe-only. - limit: "15.55 KB", + // Held children (#3404, 2026-09-13): 15.55 -> 15.60 KB, measured at + // 15562 B on the merge with `next`; see the createStore note. + limit: "15.60 KB", modifyEsbuildConfig: observeEsbuildConfig }, { From 0c48456a0c6ec4481f59ea2d145d0ece8b337d05 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 14 Sep 2026 02:04:09 -0700 Subject: [PATCH 3/3] Restore server.ts formatting (pre-commit ran a stray prettier build) Co-authored-by: Cursor --- packages/web/server-functions/src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index b958766cf..388a43fb6 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -220,7 +220,9 @@ export interface NoJSHandlerOptions { } export type ServerFunctionOriginMatcher = - string | readonly string[] | ((origin: string, request: Request) => boolean | Promise); + | string + | readonly string[] + | ((origin: string, request: Request) => boolean | Promise); /** Same-origin validation options for server function requests. */ export interface ServerFunctionCSRFOptions {