Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optimistic-mount-lane-first-pass.md
Original file line number Diff line number Diff line change
@@ -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 `<Show>` 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).
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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… |
Expand Down
26 changes: 16 additions & 10 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,18 @@ export function recompute(el: Computed<any>, 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
Expand Down Expand Up @@ -497,11 +500,7 @@ export function recompute(el: Computed<any>, 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;
Expand Down Expand Up @@ -982,9 +981,13 @@ function frameRead(c: Computed<any>, el: Signal<any> | Computed<any>): 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<any>)._statusFlags & STATUS_PENDING)) &&
!verdict &&
((c as any)._type !== EFFECT_RENDER || el._config & CONFIG_INPUTS_PUBLISHED)
)
Expand Down Expand Up @@ -1684,7 +1687,10 @@ export function read<T>(el: Signal<T> | Computed<T>): 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<any> | null)?._statusFlags! & STATUS_UNINITIALIZED))
committed = true;
else if (c !== null && c._config & CONFIG_VERDICT) {
committed = true;
GlobalQueue._observeFlight!(c as Computed<any>, owner);
Expand Down
156 changes: 156 additions & 0 deletions packages/signals/tests/optimistic-mount-nested-memo-3835.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(r => setTimeout(r, 0));

function setup(nested: boolean) {
type El = { color?: string };
let shown: El | undefined;
let run!: () => Promise<void>;
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<void>(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<void>;
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<number>(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<void>(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();
});
});
85 changes: 85 additions & 0 deletions packages/web/test/optimistic-mount-style-3835.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<void>(r => setTimeout(r, 0));

function setup(opts: { memo: boolean; dynamicText: boolean }) {
const container = document.createElement("div");
document.body.appendChild(container);
let run!: () => Promise<void>;
let finish!: () => void;

function Content(props: { enabled: () => boolean }) {
const view = () => (
<div style={{ color: props.enabled() ? "blue" : "red" }}>
Hello{opts.dynamicText ? (props.enabled() ? " world" : "") : ""}
</div>
);
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<void>(r => (finish = r));
});
run = () => act();
return (
<>
<Show when={visible()}>
<Content enabled={enabled} />
</Show>
</>
);
}

const dispose = render(() => <App />, 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: '<div style="color: red;">Hello</div>',
after: dynamicText
? '<div style="color: blue;">Hello world</div>'
: '<div style="color: blue;">Hello</div>'
});
});
});
Loading