Summary
WASTED_RECOMPUTE reports every re-run of a createProjection as "produced the same value", including runs that changed the store. The projection's internal computed never returns a value (its work is the draft writes, or the reconcile of a returned value), so core's equality gate always sees undefined === undefined and records the run as unchanged. Once a projection re-runs often enough to pass the 2 ms budget, the diagnostic fires on work that was not wasted, and the repair it suggests (an equality boundary upstream) cannot apply.
We hit it in @dschz/solid-flow: during a node drag, each dragged node's row projection and its edges' row projections re-run once per pointer move and write the new position. On a fast machine the runs stay under the budget; on a GitHub Actions runner (or with CPU throttling) the dev build warns [WASTED_RECOMPUTE] memo "internalNodes.row" re-ran 10 times in 1000ms and 10 of those produced the same value while the node visibly moves on every one of those runs.
Reproduction (headless, only solid-js)
Fresh project: npm i solid-js@2.0.0-rc.11, "type": "module", run with node --conditions=browser --conditions=development repro.mjs. The budget is set to 0 ms so the result does not depend on machine speed; the two memos are controls.
import { createMemo, createProjection, createRoot, createSignal, flush } from "solid-js";
import { attribution } from "solid-js/attribution";
const OPTS = { log: false, wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 0, windowMs: 60000 } };
function measure(label, make) {
const warnings = [];
const warn = console.warn;
console.warn = (msg) => warnings.push(String(msg));
attribution.enable(OPTS);
const [x, setX] = createSignal(0);
let read, dispose;
createRoot((d) => {
dispose = d;
read = make(x, label);
});
const seen = [];
for (let i = 1; i <= 10; i++) {
setX(i);
flush();
seen.push(read());
}
attribution.disable();
console.warn = warn;
dispose();
const flagged = warnings.filter((w) => w.startsWith("[WASTED_RECOMPUTE]")).length;
console.log(`${label.padEnd(30)} values ${seen.join(",")} WASTED_RECOMPUTE: ${flagged}`);
}
// projection that mutates its draft (returns undefined)
measure("projection, mutate draft", (x, name) => {
const s = createProjection((d) => { d.v = x(); }, { v: 0 }, { name });
return () => s.v;
});
// projection that returns a fresh value (reconciled)
measure("projection, return value", (x, name) => {
const s = createProjection(() => ({ v: x() }), { v: 0 }, { name });
return () => s.v;
});
// controls
measure("memo, changing (control)", (x, name) => createMemo(() => x(), { name }));
measure("memo, constant (control)", (x, name) => createMemo(() => (x(), 7), { name }));
Results (Node 24.13, macOS, Apple Silicon, solid-js 2.0.0-rc.11)
projection, mutate draft values 1,2,3,4,5,6,7,8,9,10 WASTED_RECOMPUTE: 1
projection, return value values 1,2,3,4,5,6,7,8,9,10 WASTED_RECOMPUTE: 1
memo, changing (control) values 1,2,3,4,5,6,7,8,9,10 WASTED_RECOMPUTE: 0
memo, constant (control) values 7,7,7,7,7,7,7,7,7,7 WASTED_RECOMPUTE: 1
The controls behave as documented: a memo whose value changes is not flagged, a memo that keeps returning the same value is. Both projections are flagged although every run changed what s.v reads. With log: true the per-run trace says the same thing, e.g. [why-run] memo "projection, mutate draft" ran (run 3, 0.02ms, unchanged) for a run that moved v from 2 to 3.
Where it goes wrong (rc.11 source)
store/next/projection.ts L196-L199: the projection's node is computed(() => { …; runProjectionComputedNext(store, fn, key); }), so its value is undefined on every run, whether fn wrote the draft or returned a value to reconcile.
- Core's equality gate therefore reports
changed = false for every projection run, and checkWastedRecompute counts each one as wasted.
recomputeEnd already recognizes the case for effects: L4098-L4108 says "undefined outputs are exempt — a side-effect-only compute's work IS its effect phase, and identity of undefined proves nothing". That exemption only guards the effect re-derivation; a projection's computed has the same shape (its work is the store write) but reaches the check with changed already false from core.
Impact
Any projection that re-runs often, e.g. a per-row projection that follows a gesture, eventually trips the warning in the dev build, and the diagnostic's own advice ("put an equality boundary upstream") does not apply because the runs are not redundant. Tests that assert a clean dev console (we pin one in an SSR/hydration smoke) become machine-speed dependent: green locally, red on a slower CI runner. costs().wastedMs over-reports by the same amount.
Possible directions
- Report a projection run as changed when it wrote to its store or reconciled a returned value (the family knows whether
runProjectionComputedNext touched anything).
- Or exempt projection nodes from
checkWastedRecompute, as the undefined-output rule already does for side-effect-only computes.
Platform
solid-js 2.0.0-rc.11 (@solidjs/signals 2.0.0-rc.11, ee49b3ee); also seen on 2.0.0-rc.10. Node 24.13 with the browser + development conditions; the same warnings in Chromium through Vite's dev build.
Summary
WASTED_RECOMPUTEreports every re-run of acreateProjectionas "produced the same value", including runs that changed the store. The projection's internal computed never returns a value (its work is the draft writes, or the reconcile of a returned value), so core's equality gate always seesundefined === undefinedand records the run as unchanged. Once a projection re-runs often enough to pass the 2 ms budget, the diagnostic fires on work that was not wasted, and the repair it suggests (an equality boundary upstream) cannot apply.We hit it in
@dschz/solid-flow: during a node drag, each dragged node's row projection and its edges' row projections re-run once per pointer move and write the new position. On a fast machine the runs stay under the budget; on a GitHub Actions runner (or with CPU throttling) the dev build warns[WASTED_RECOMPUTE] memo "internalNodes.row" re-ran 10 times in 1000ms and 10 of those produced the same valuewhile the node visibly moves on every one of those runs.Reproduction (headless, only
solid-js)Fresh project:
npm i solid-js@2.0.0-rc.11,"type": "module", run withnode --conditions=browser --conditions=development repro.mjs. The budget is set to 0 ms so the result does not depend on machine speed; the two memos are controls.Results (Node 24.13, macOS, Apple Silicon, solid-js 2.0.0-rc.11)
The controls behave as documented: a memo whose value changes is not flagged, a memo that keeps returning the same value is. Both projections are flagged although every run changed what
s.vreads. Withlog: truethe per-run trace says the same thing, e.g.[why-run] memo "projection, mutate draft" ran (run 3, 0.02ms, unchanged)for a run that movedvfrom 2 to 3.Where it goes wrong (rc.11 source)
store/next/projection.tsL196-L199: the projection's node iscomputed(() => { …; runProjectionComputedNext(store, fn, key); }), so its value isundefinedon every run, whetherfnwrote the draft or returned a value to reconcile.changed = falsefor every projection run, andcheckWastedRecomputecounts each one as wasted.recomputeEndalready recognizes the case for effects: L4098-L4108 says "undefinedoutputs are exempt — a side-effect-only compute's work IS its effect phase, and identity ofundefinedproves nothing". That exemption only guards the effect re-derivation; a projection's computed has the same shape (its work is the store write) but reaches the check withchangedalreadyfalsefrom core.Impact
Any projection that re-runs often, e.g. a per-row projection that follows a gesture, eventually trips the warning in the dev build, and the diagnostic's own advice ("put an equality boundary upstream") does not apply because the runs are not redundant. Tests that assert a clean dev console (we pin one in an SSR/hydration smoke) become machine-speed dependent: green locally, red on a slower CI runner.
costs().wastedMsover-reports by the same amount.Possible directions
runProjectionComputedNexttouched anything).checkWastedRecompute, as theundefined-output rule already does for side-effect-only computes.Platform
solid-js 2.0.0-rc.11 (
@solidjs/signals2.0.0-rc.11,ee49b3ee); also seen on 2.0.0-rc.10. Node 24.13 with the browser + development conditions; the same warnings in Chromium through Vite's dev build.