Repository navigation
Description
Summary
@solidjs/[email protected] through rc.21, and next at ce5b288e3 (prod and dev builds). Regression since rc.13, introduced by #3774 (1b9ceb679, the hold model): its parent 41fdf9696 runs the repro below linearly, 1b9ceb679 quadratically.
A projection B that reads a leaf of another projection A can run before A has re-derived in the same flush. B then computes against A's previous value, A re-derives, and B runs again. When a projection R reads N such Bs, each late A also re-runs R, and the flush is quadratic in N.
It happens when A sits higher in the heap than B, and the simplest way to get there is for A to read a memo. A projection's leaves are plain signals, so a reader of them gets no height from the projection's derive. If A reads only store keys, A and B both stay at height 0 and creation order happens to run A first. One memo read (per-A or shared) puts A at height 1 and B runs first. The pull that should fix the order on read (pullFamily) does not bring the derive up to date for an untracked read (details below).
We hit it in @dschz/solid-flow: a 10,000-node mount went from 2.3 s on rc.13 to 17.5 s on rc.21 with the same library build (Impact, below).
Reproduction (headless, only @solidjs/signals)
A[i] reads a memo and a store key size[i]; B[i] reads A[i] and A[i+1]; R reads every B[i]. One write sets every size[i].
mkdir solid-memo-projection && cd solid-memo-projection
npm init -y >/dev/null && npm pkg set type=module && npm i @solidjs/[email protected] >/dev/null
cat > repro.mjs <<'JS'
import { createEffect, createMemo, createProjection, createRoot, createStore, flush } from "@solidjs/signals";
const MEMO = !process.argv.includes("--no-memo");
const run = (N) => {
let bRuns = 0, rRuns = 0, setSizes;
const dispose = createRoot((dispose) => {
const [sizes, set] = createStore(Object.fromEntries(Array.from({ length: N }, (_, i) => [i, 0])));
setSizes = set;
const A = Array.from({ length: N }, (_, i) => {
const memo = createMemo(() => ({ id: i }));
return createProjection(() => ({ id: MEMO ? memo().id : i, size: sizes[i] }), { id: -1, size: 0 });
});
const B = Array.from({ length: N - 1 }, (_, i) =>
createProjection(() => { bRuns++; return { ok: A[i].size > 0 && A[i + 1].size > 0 }; }, { ok: false }),
);
const R = createProjection((draft) => {
rRuns++;
let n = 0;
for (const b of B) if (b.ok) n++;
draft.ok = n;
}, { ok: 0 });
createEffect(() => R.ok, () => {});
return dispose;
});
flush();
bRuns = 0; rRuns = 0;
const t0 = performance.now();
setSizes((draft) => { for (let i = 0; i < N; i++) draft[i] = 1; });
flush();
const ms = Math.round(performance.now() - t0);
dispose();
return { N, ms, bRuns, rRuns };
};
run(100);
for (const N of [1000, 2000]) console.log(MEMO ? "memo " : "no memo", JSON.stringify(run(N)));
JS
node repro.mjs && node repro.mjs --no-memoFor the expected output, install @solidjs/[email protected] instead. Pin signals directly: [email protected] alone pulls the newest signals through its caret range.
Results (Node 24.13, macOS, Apple Silicon)
@solidjs/signals |
N | flush, ms | B runs |
R runs |
|---|---|---|---|---|
| 2.0.0-rc.13 | 2,000 | 12 | 1,999 | 1 |
| 2.0.0-rc.14 | 2,000 | 401 | 3,998 | 1,999 |
| 2.0.0-rc.21 | 2,000 | 487 | 3,998 | 1,999 |
2.0.0-rc.21, --no-memo |
2,000 | 16 | 1,999 | 1 |
next ce5b288e3 (built from source) |
2,000 | 480 | 3,998 | 1,999 |
41fdf9696 (parent of #3774) |
1,000 | 4 | 999 | 1 |
1b9ceb679 (#3774) |
1,000 | 105 | 1,998 | 999 |
The run order at N=4, logging each derive with the values it read:
rc.21 rc.13
A0(size=1) A0(size=1)
B0(reads A0.size=1, A1.size=0) <- stale A1(size=1)
A1(size=1) A2(size=1)
B0(reads A0.size=1, A1.size=1) A3(size=1)
B1(reads A1.size=1, A2.size=0) <- stale B0(reads A0.size=1, A1.size=1)
R(ok=1) B1(reads A1.size=1, A2.size=1)
A2(size=1) B2(reads A2.size=1, A3.size=1)
B1(reads A1.size=1, A2.size=1) R(ok=3)
B2(reads A2.size=1, A3.size=0) <- stale
R(ok=2)
A3(size=1)
B2(reads A2.size=1, A3.size=1)
R(ok=3)
Every B[i] reads A[i+1].size before A[i+1] has derived. Each R pass reads all the Bs, so at N rows R runs N-1 times and the flush does O(N²) row reads.
Where it goes wrong (next, ce5b288e3)
store/store.tsL1884-L1938:pullFamilydocuments "before any value is served through a family target, the derive — the firewall — is brought up to date WITHOUT the reader subscribing to it … and the reader's height". The settled branch does it withuntrack(() => readNode(fw))(L1938).core/core.tsL2239: an untrackedreadof a computed only callsprepareComputed(el, false), which wakes a lazy or dormant node and does not refresh a stale one (L2009-L2038). The pull itself,pullComputed, is on the tracked branch only (L2320-L2324). Logging insidepullFamily(dev build, N=3) shows it reached forA1whileB0runs, withB0at height 0 andA1at height 1, andA1's flagsREACTIVE_IN_HEAPonly (noCHECK/DIRTY): the untracked read servesA1's old leaves.core/heap.tsL136-L148:adjustHeightcounts only deps with a_fn. A store leaf has none, so a reader of a projection's leaves never rises above the projection's derive.- Before signals: the hold model (L2) — core rebuilt from its rulings; −23% floor, −18% hello world, 0 regressions #3774 both cases went through the leaf's
_firewall.heap.tsL158-L165 at41fdf9696took the firewall's height (dep1._firewall || dep1),core.tsL966-L972 checked the firewall inupdateIfNecessary, andreadresolvedowner = firewall || el(L2118), then pulled it and lifted the reader above it (L2164-L2186). signals: the hold model (L2) — core rebuilt from its rulings; −23% floor, −18% hello world, 0 regressions #3774 removed that path (theCARVE 1note atcore.tsL2216-L2220) and left the pull topullFamily.
An experiment that fixes the repro
Giving pullFamily's settled branch the pull a tracked read makes:
} else {
+ let reader: any = context;
+ if (reader?._root) reader = reader._parentComputed;
+ if (reader) pullComputed(fw, reader);
untrack(() => readNode(fw));(plus importing pullComputed from ../core/core.js). With it, on next at ce5b288e3 and on the @solidjs/[email protected] tag, the repro runs each B and R once (N=2000: 10-13 ms, bRuns 1,999, rRuns 1) and the N=4 trace matches rc.13's. On next, the packages/signals suite gives the same result with and without it (489 files: 6,813 passed, 14 expected fail, 2 skipped). This is an experiment to locate the cause, not a reviewed fix: it ignores the verdict, lane and hold rules around the call.
Impact
Any projection read by another projection's derive is affected: per-row projections over per-row projections, keyed records over rows, anything that joins two derived stores. The cost scales with the size of the write: a write that dirties K upstream rows can re-run each downstream reader up to K times.
In @dschz/solid-flow (one projection per node that reads a per-row memo, one per edge that reads its two endpoint nodes, a keyed record over the edge rows), at 10,000 nodes, Apple M4 Pro, Chromium, production builds, medians of two passes. The rc.21 and experiment columns are the same library build on the same day. The rc.13 column is our last benchmark round on rc.13, from 2026-10-02 at a library commit a few patches older. Built today against rc.13, that library build mounts in 2.3 s and against rc.21 in 17.5 s.
| Row | rc.13 (2026-10-02) | rc.21 | rc.21 + the experiment |
|---|---|---|---|
| Mount to every node measured, ms | 2,180 | 17,495 | 2,246 |
| Selection drag, script ms | 100 | 157 | 109 |
| Move all nodes in one write, ms | 315 | 434 | 360 |
| Box selection, script ms | 77 | 96 | 84 |
| Heap on mount, MB | 609 | 579 | 578 |
The mount profile is all inside the edge record's derive re-reading its rows: about 50 million row reads at 10k.
Possible directions
pullFamilypulls the derive for any reader, tracked or not (the experiment above), so its documented contract holds.- Or let a projection's leaves carry the height of their derive again, so the heap orders a reader after the derive, as before signals: the hold model (L2) — core rebuilt from its rulings; −23% floor, −18% hello world, 0 regressions #3774.
Platform
@solidjs/signals 2.0.0-rc.14 to 2.0.0-rc.21, and next at ce5b288e3 built from source. Node 24.13 on macOS (Apple Silicon), prod and dev builds (--conditions=development). The same in Chromium through solid-flow's production build.
Activity
[-]rc.14: a projection that reads a memo is read stale by another projection's derive, so a derive over N such readers re-runs N times (O(N²) per flush)[/-][+][2.0 rc.21]: a projection that reads a memo is read stale by another projection's derive, so a derive over N such readers re-runs N times (O(N²) per flush)[/+]fix(signals): a projection's reader pulls the derive it reads through (…