From 5c2174cf949f21bda7afe0faae1f2649551c52b5 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 6 Oct 2026 13:56:30 -0500 Subject: [PATCH] fix(signals): seat new nodes in their creator's lane from the start A memo created by a lane pass ran its body outside the lane, so render effects it created read the transaction's staged value and were born held: the lane revealed the memo's element before they ran (#3835). A reader with no committed value yet keeps its previous reads of a pending flight, so a Loading mounted by the lane still shows its fallback (#3540) and a render effect stays a stale reader. --- .../optimistic-mount-lane-first-pass.md | 5 + packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/core/core.ts | 26 +-- .../optimistic-mount-nested-memo-3835.test.ts | 156 ++++++++++++++++++ .../test/optimistic-mount-style-3835.spec.tsx | 85 ++++++++++ 5 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 .changeset/optimistic-mount-lane-first-pass.md create mode 100644 packages/signals/tests/optimistic-mount-nested-memo-3835.test.ts create mode 100644 packages/web/test/optimistic-mount-style-3835.spec.tsx diff --git a/.changeset/optimistic-mount-lane-first-pass.md b/.changeset/optimistic-mount-lane-first-pass.md new file mode 100644 index 000000000..78d3b1dc8 --- /dev/null +++ b/.changeset/optimistic-mount-lane-first-pass.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A node created by a lane pass is the lane's from the start of its first pass, not only at its end (#3835). A memo mounted by an optimistic write (a `` over an optimistic signal) used to run its body outside the lane, so the render effects it created read the transaction's staged value and were born held: the lane revealed the memo's element before those effects had run once. They now read the screen like the rest of the lane's frame and run at the reveal. A pending flight reads as it did before for a node with no committed value yet: a memo sees it pending, held or not, so a `Loading` the lane mounts shows its fallback (A29's boundary exemption, #3540), and a render effect is still a stale reader of a held flight (rule 3). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 00bbd1f2e..37c053284 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:107` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:293` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:91` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:115` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×9 effect.ts×1 scheduler.ts×3 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:115` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×3 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:249` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:123` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:131` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 2ee50e388..4fc80cf4d 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -258,15 +258,18 @@ export function recompute(el: Computed, create: boolean = false): void { // (REACTIVE_LANE_DIRTY, lanes.ts — a leaf the lane owns). A written guess's // own pass is its truth arriving (A18), the frame's. A tracked read of a // lane's value moves a derivation's pass into the lane (`read`); a leaf's - // never moves. Restored at the end, after this pass's staging and runs - // have been routed. + // never moves. A first pass sits in its creator's lane from its start + // (ruling A), so what its body creates and reads is the lane's too (#3835). + // Restored at the end, after this pass's staging and runs have been routed. const prevLane = passLane; setPassLane( (el._flags & REACTIVE_LANE_DIRTY || (el._config & (CONFIG_OVERRIDE | CONFIG_GUESS)) === CONFIG_OVERRIDE) && el._x?._transaction?._lane ? el._x._transaction - : null + : create && creatorPass(context)?._flags! & REACTIVE_RECOMPUTING_DEPS + ? prevLane + : null ); // Attribution hook: fired before this run touches the dep list — `_deps` // still holds the previous run's links (the subscriptions that could have @@ -497,11 +500,7 @@ export function recompute(el: Computed, create: boolean = false): void { // that read none of the lane's world has left it: its result is the // frame's (a derivation whose branch no longer reaches the guess). A // guess is written, not derived — it never leaves this way. - let lane = - passLane ?? - (create && (creatorPass(oldcontext)?._flags ?? 0) & REACTIVE_RECOMPUTING_DEPS - ? prevLane - : null); + let lane = passLane; // Listed before its staging, a pending pass included (the lane's own // flight is the lane's); false: the pass left the lane (lanes.ts). const errored = !!el._x?._error; @@ -982,9 +981,13 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // leaf — the transaction's own later passes read it — so a mainline mount's // derivations carry the future (A29, born held); only its direct bindings // read the screen. + // Lane work with no committed value yet reads a flight as a mount's memo + // does: it enters, and the boundary it mounts catches the pending (#3540). const verdict = c._config & CONFIG_VERDICT; if ( - passLane === null && + (passLane === null || + (c._statusFlags & STATUS_UNINITIALIZED && + (el as Computed)._statusFlags & STATUS_PENDING)) && !verdict && ((c as any)._type !== EFFECT_RENDER || el._config & CONFIG_INPUTS_PUBLISHED) ) @@ -1684,7 +1687,10 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(owner._statusFlags & STATUS_UNINITIALIZED) ) { - if (passLane !== null) committed = true; + // A reader with no committed value yet sees the flight pending: a boundary + // a lane mounts shows its fallback (A29's boundary exemption, #3540). + if (passLane !== null && !((c as Computed | null)?._statusFlags! & STATUS_UNINITIALIZED)) + committed = true; else if (c !== null && c._config & CONFIG_VERDICT) { committed = true; GlobalQueue._observeFlight!(c as Computed, owner); diff --git a/packages/signals/tests/optimistic-mount-nested-memo-3835.test.ts b/packages/signals/tests/optimistic-mount-nested-memo-3835.test.ts new file mode 100644 index 000000000..8c25508be --- /dev/null +++ b/packages/signals/tests/optimistic-mount-nested-memo-3835.test.ts @@ -0,0 +1,156 @@ +/** + * #3835 — a subtree mounted by an optimistic write is the lane's frame + * (ruling A), all the way down. A memo created by the lane pass used to sit + * outside the lane while its own body ran, so the render effect it created + * read the transaction's staged value and was born held: the lane revealed + * the memo's element with the effect's first run still waiting on the + * transaction. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + createLoadingBoundary, + untrack +} from "../src/index.js"; + +afterEach(() => flush()); + +const tick = () => new Promise(r => setTimeout(r, 0)); + +function setup(nested: boolean) { + type El = { color?: string }; + let shown: El | undefined; + let run!: () => Promise; + let finish!: () => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [enabled, setEnabled] = createSignal(false); + const [visible, setVisible] = createOptimistic(enabled); + const content = () => { + const el: El = {}; + createRenderEffect( + () => (enabled() ? "blue" : "red"), + v => { + el.color = v; + } + ); + return el; + }; + const view = createMemo(() => { + if (!visible()) return undefined; + return nested ? createMemo(content) : () => content(); + }); + createRenderEffect( + () => view()?.(), + el => { + shown = el; + } + ); + const act = action(function* () { + setEnabled(true); + setVisible(true); + yield new Promise(r => (finish = r)); + }); + run = () => act(); + }); + flush(); + return { + shown: () => (shown ? { ...shown } : undefined), + run: () => run(), + finish: () => finish(), + dispose: () => dispose() + }; +} + +describe("#3835 optimistic mount of a nested memo", () => { + for (const nested of [true, false]) + it(`${nested ? "memo-wrapped" : "direct"} content reveals with the screen value`, async () => { + const s = setup(nested); + expect(s.shown()).toBeUndefined(); + const p = s.run(); + flush(); + await tick(); + flush(); + expect(s.shown()).toEqual({ color: "red" }); + s.finish(); + await p; + await tick(); + flush(); + expect(s.shown()).toEqual({ color: "blue" }); + s.dispose(); + }); +}); + +describe("#3835 a Loading mounted by an optimistic write over a parent flight", () => { + // A29's boundary exemption holds for lane work too: the boundary's first + // pass sees the flight pending, held or not, and shows its fallback; it is + // not handed the flight's committed value. + for (const order of ["same tick", "after the park"]) + it(`shows its fallback (${order})`, async () => { + const log: unknown[] = []; + let run!: () => Promise; + let finish!: () => void; + let dispose!: () => void; + const lands: ((v: number) => void)[] = []; + createRoot(d => { + dispose = d; + const [count, setCount] = createSignal(1); + const [show, setShow] = createSignal(false); + const data = createMemo(() => { + const v = count(); + return v === 1 ? v : new Promise(r => lands.push(r)); + }); + const [visible, setVisible] = createOptimistic(show); + const view = createMemo(() => + visible() + ? untrack(() => + createLoadingBoundary( + () => `data ${data()}`, + () => "fallback" + ) + ) + : () => "hidden" + ); + createRenderEffect( + () => view()(), + v => { + log.push(v); + } + ); + const act = action(function* () { + setCount(2); + if (order === "after the park") yield tick(); + setShow(true); + setVisible(true); + yield new Promise(r => (finish = r)); + }); + run = () => act(); + }); + flush(); + const p = run(); + for (let i = 0; i < 2; i++) { + flush(); + await tick(); + } + flush(); + expect(log).toEqual(["hidden", "fallback"]); + lands.forEach(land => land(2)); + await tick(); + flush(); + expect(log).not.toContain("data 1"); + finish(); + await p; + await tick(); + flush(); + expect(log.at(-1)).toBe("data 2"); + expect(log).not.toContain("data 1"); + dispose(); + }); +}); diff --git a/packages/web/test/optimistic-mount-style-3835.spec.tsx b/packages/web/test/optimistic-mount-style-3835.spec.tsx new file mode 100644 index 000000000..45c1c6691 --- /dev/null +++ b/packages/web/test/optimistic-mount-style-3835.spec.tsx @@ -0,0 +1,85 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3835: a component mounted by an optimistic write reveals with the lane's + * screen values. Its memo's render effects used to be born held by the + * transaction, so the element showed before its style was ever applied. + */ +import { describe, expect, test } from "vitest"; +import { action, createMemo, createOptimistic, createSignal, flush, Show } from "solid-js"; +import { render } from "@solidjs/web"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +function setup(opts: { memo: boolean; dynamicText: boolean }) { + const container = document.createElement("div"); + document.body.appendChild(container); + let run!: () => Promise; + let finish!: () => void; + + function Content(props: { enabled: () => boolean }) { + const view = () => ( +
+ Hello{opts.dynamicText ? (props.enabled() ? " world" : "") : ""} +
+ ); + return opts.memo ? createMemo(view) : view(); + } + + function App() { + const [enabled, setEnabled] = createSignal(false); + const [visible, setVisible] = createOptimistic(enabled); + const act = action(function* () { + setEnabled(true); + setVisible(true); + yield new Promise(r => (finish = r)); + }); + run = () => act(); + return ( + <> + + + + + ); + } + + const dispose = render(() => , container); + return { + container, + run: () => run(), + finish: () => finish(), + dispose: () => { + dispose(); + container.remove(); + } + }; +} + +describe("#3835 optimistic mount applies its bindings at the reveal", () => { + for (const memo of [true, false]) + for (const dynamicText of [true, false]) + test(`memo=${memo} dynamicText=${dynamicText}`, async () => { + const s = setup({ memo, dynamicText }); + flush(); + expect(s.container.innerHTML).toBe(""); + const p = s.run(); + flush(); + await tick(); + flush(); + const during = s.container.innerHTML; + s.finish(); + await p; + await tick(); + flush(); + const after = s.container.innerHTML; + s.dispose(); + expect({ during, after }).toEqual({ + during: '
Hello
', + after: dynamicText + ? '
Hello world
' + : '
Hello
' + }); + }); +});