Skip to content

2.0.0-rc.11 (diagnostics): WASTED_RECOMPUTE reports every createProjection re-run as unchanged, including runs that changed the store #3715

Description

@thedanchez

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions