Skip to content
Merged
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/lean-posture-landed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Document the lean-posture contract for `why()`/`subscriptions()` on the records channel (`why` shares `history("rerun")`'s gate; `subscriptions` reads the graph and is unaffected); ratchet the engine-cost tripwire cap to 4.
116 changes: 68 additions & 48 deletions documentation/plans/responsiveness-findings-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ Three facts, in order of weight:
keep their window on the node (`_dev*` fields); a per-run `WeakMap`,
`now()` or allocation is the shape to refuse in review. The
`attribution-engine-cost` tripwire (enabled/idle ratio, best-of-k,
interleaved, cap 14 against a measured ~10) is what catches it.
interleaved, cap 4 against a measured ~2.0–2.1× folded — see Lean
posture below) is what catches it.
- **The engine's fixed per-re-run cost is the number that matters**: ~435 ns
above idle with every check off, ~9× idle. It is the `RerunEvent` — the
`causes` array, `depsAdded`/`depsRemoved` name arrays, `preview()` strings
Expand All @@ -296,53 +297,72 @@ Three facts, in order of weight:
adapter that holds the engine for interaction/hold tracing, whether or
not it reads a re-run.

### Lean posture — proposal, needs a decision

_Status._ Landed as proposed: `wantsRerun()` — a `rerun` listener on
`OBSERVE.records`, an imported fold (`costs`/`feedback`) or `log` — gates the
record at run start; the checks read the frame's facts; `history("rerun")`
is empty while nothing wants records. The text below is the proposal as
written.

Build the `RerunEvent` only when someone can read it. The engine knows at
`recomputeEnd` whether anyone can: a `rerun` subscriber, a registered fold
(`costs`/`feedback`/`why`/`subscriptions` import), `log: true`, or a
consumer that will call `history("rerun")`. When none holds, keep only what the
other records need — the frame's interaction for `runs`/`runMs` on
`InteractionEvent`, the cause→interaction link for holds and flights, the
per-node counters the checks read — and skip the record: no causes array,
no dep-name diffs, no previews, no ring-buffer push, no `recordSubject`.
Expected: enabled cost for a records-only consumer falls from ~9× idle
toward 3–4×; measure before promising.

What it changes, and therefore what to decide:

- `history("rerun")`, `why()`, `subscriptions()` on a lean engine return nothing
for runs that happened before a consumer of them appeared. Either
document that (they are dev-console tools; the observe consumer that
wants them subscribes to `rerun` or imports a fold, which turns records
on from that moment), or add an explicit `enable({ reruns: true })` that
forces record-building — the most-demanding merge makes that compose.
- `OBSERVE.records.subscribe("rerun", …)` must turn record-building on, the
way the `create`/`effect`/`flush`/`flight`/`fallback` timeline records
already work ("subscribing is what turns them on").
- The checks that read the record today (`checkHotRuns` reads
`event.causes` for its cause key and message; `checkWastedRecompute`
reads `changed`, `selfMs`, `phase`, `at`) need those facts from the frame
instead of the record — they are all on the frame before the record is
built.
- `@sentry/solid-2` subscribes to `rerun` only to fold a per-interaction
hot list; `InteractionEvent.runs`/`runMs` and the `HOT_SCOPE_*` /
`WASTED_RECOMPUTE` findings cover that, so the adapter can drop the
subscription and become a lean consumer. The Performance Tracks adapter
needs re-run records by design and stays a full one.

Not a shortcut: the record is the engine's unit of truth for the
dev-console tools, and a lean engine must produce the identical record the
moment a consumer asks. The proof is the existing attribution suite run
twice — once with a `rerun` subscriber armed, once without — and the
engine-cost tripwire's ratio dropping, with its cap ratcheted to hold the
gain.
### Lean posture — LANDED (#3644)

Build the `RerunEvent` only when someone can read it. Implemented as
`wantsRerun() = log || folds.length > 0 || OBSERVE.records.observed("rerun")`
(`packages/signals/src/core/attribution.ts`), read at `recomputeStart` —
it decides whether the dep snapshot the record's subscription diff needs
is captured (`frame.prevDeps`, `null` when nothing wants the record) — and
honoured at `recordRerun`. Without an audience the run still leaves its
facts on the node (run sequence and count, causes, interaction), feeds the
interaction and flush counters and runs every check; the record — cause
copy, dep diff, previews — the ring-buffer push, the fold and the emit are
skipped. Not `recomputeEnd` as proposed: the snapshot is the first cost,
so the decision has to precede the run, and a listener arriving mid-run
gets the next record. Pinned in `tests/attribution-lean-gate.test.ts`.

Decision on the contract — document, no option. There is no
`enable({ reruns: true })`: a consumer that wants re-run records subscribes
to `rerun` or imports a fold, the same "subscribing is what turns them on"
the timeline records already had, so the engine has one gate rather than
two that compose. The questions the proposal listed, as answered:

- `history("rerun")` is empty for runs made while nothing wanted a record
and holds every record from the moment something did; the run count
kept meanwhile is on the first record (`nodeRuns`). Documented on
`Attribution.history`, on `RecordTypes` (`dev.ts`) and in
`08-dev-diagnostics.md`. `why(node)` is a view of that buffer and shares
its gate (a node whose runs left no record has no `nodeId` and no
history) — documented on `why`. `subscriptions(node)` was never a record
reader: it walks the node's live `_deps`, so it answers with or without
an audience (and with the engine disabled) — documented on
`subscriptions`. Both pinned in the lean-gate test.
- `OBSERVE.records.subscribe("rerun", …)` turns record-building on from the
next run start and off again when the listener unsubscribes; `log: true`
and a registered fold (`costs`/`feedback` import) do the same. The bare
`attribution.subscribe(listener)` went with the channel consolidation in
the same PR; there is no untyped form to gate.
- The checks read the frame, not the record: `checkEffectCycle`,
`checkRelayTear`, `checkHotRuns` (causes), `checkHotTime` (`selfMs`,
causes), `checkWastedRecompute` (`frame.start`, `phase`, `changed`,
`selfMs`, causes) and `checkDepWidth` all run in `recordRerun` before the
`prevDeps === null` return that is the gate. `HOT_SCOPE_RERUNS` and
`WASTED_RECOMPUTE` firing without a record are pinned.
- `@sentry/solid-2` dropping its `rerun` subscription to become a lean
consumer (`InteractionEvent.runs`/`runMs` and the `HOT_SCOPE_*` /
`WASTED_RECOMPUTE` findings cover its per-interaction hot list) is the
follow-up in the Sentry PR, getsentry/sentry-javascript#24517, together
with the channel migration. The Performance Tracks adapter needs re-run
records by design and stays a full consumer.

Measured, `attribution-engine-cost` (enabled/idle ratio on the observe
artifacts, best-of-5 interleaved, under vitest, M-series). The section's
table above (490/55 ns ≈ 9×) came from a different flush-per-write
micro-harness; the tripwire's apples-to-apples numbers are the baseline
from here:

| Engine | folded | listened | lean |
| ------------------------------------------ | ---------- | ---------- | ---------- |
| #3613 (record always built), 2026-09-24 | ~2.5–2.8× | — | — |
| #3644 (one channel, lean gate), 2026-09-24 | ~2.1–2.2× | ~2.0–2.3× | ~1.8–1.9× |
| #3644 + #3646, 2026-09-24, six runs | 1.98–2.14× | 1.91–2.11× | 1.71–1.90× |

The gap between postures is inside this harness's noise; what the gate
buys is the allocation and GC pressure of the record, which a 5,000-re-run
ratio does not resolve. Cap ratcheted 14 → 4 (~85–100% headroom over the
folded baseline; trips when the engine costs roughly double what it does
today, the same discipline as `observe-idle-cost`'s 1.25 over 1.03–1.09).

## Consumers — showing the facts where developers already look

Expand Down
4 changes: 2 additions & 2 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1010,8 +1010,8 @@ import { costs, feedback, why, subscriptions, formatRerun, formatOrigin } from "

costs(); // { scopes, writes } ranked cost tables (since enable())
feedback(); // responsiveness tables (below)
why(someMemo); // re-run history for one node
subscriptions(fn); // current dep names of one scope
why(someMemo); // re-run history for one node — a view of history("rerun"), same gate: empty for runs nothing wanted a record of
subscriptions(fn); // current dep names of one scope — read from the graph, not a record; unaffected by the gate
formatRerun(event); // the console line for one RerunEvent
formatOrigin(origin); // `click on button#next "Next →"` — a ChangeOrigin as a name

Expand Down
11 changes: 9 additions & 2 deletions packages/signals/src/core/attribution-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ function nodeOf(target: unknown): Computed<any> {
* diagnostics bridge holds no node, only the `nodeName` the records
* carry). Records name their scope by `nodeId`; a node that has never run
* under the engine has none, and no history. By name, every scope of that
* name answers.
* name answers. A view of `history("rerun")`, so it shares that buffer's
* gate: runs nothing wanted a record of (no `rerun` listener, fold or log
* at the time) left no record and are not here — a console session that
* wants them subscribes or imports a fold first.
*/
export function why(target: unknown): RerunEvent[] {
const history = attribution.history("rerun");
Expand All @@ -28,7 +31,11 @@ export function why(target: unknown): RerunEvent[] {
return history.filter(event => event.nodeId === id);
}

/** Current dependency names of one scope — the devtools subscription view. */
/**
* Current dependency names of one scope — the devtools subscription view.
* Read from the graph, not a record, so it answers with or without an
* audience for re-run records (and with the engine disabled).
*/
export function subscriptions(target: unknown): string[] {
const node = nodeOf(target);
const names: string[] = [];
Expand Down
41 changes: 26 additions & 15 deletions packages/signals/tests/attribution-engine-cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* - listened — a `rerun` subscriber on the records channel: the record is
* built, kept and delivered;
* - folded — the `costs`/`feedback` folds loaded as well, as a consumer that
* imports the `attribution` entry has them. The cap is on this one.
* imports the `attribution` entry has them. The cap is on this one; the
* other two are measured for the failure message.
*
* The engine is imported from its core module, not the entry, so the folds
* (which register on import) arrive only when the test loads them.
Expand Down Expand Up @@ -127,21 +128,31 @@ describe.skipIf(!existsSync(OBSERVE) || !existsSync(ENGINE))("attribution engine

// The folds, as a consumer of the `attribution` entry has them from
// import; from here on every re-run is folded into the cost and
// feedback tables as well. Measured 2026-09-23 (M-series, engine at
// #3613, folds, record always built): ~9.5–10x — the RerunEvent
// (causes, dep diffs, previews), the history ring buffer,
// recordSubject, the six checks (~10% of the whole) and emitRecord.
// Re-measured 2026-09-24 on the same class of machine, interleaved with
// that engine: #3613 ~2.5–2.8x; records on one channel (no per-emit
// allocation, no subject map, checks off the facts) folded ~2.1–2.2x,
// listened ~2.0–2.3x, lean ~1.8–1.9x. The cap trips when the folded
// engine costs ~40% more per re-run than the #3613 figure; a check that
// grew a map lookup or a clock read on the hot path moves this by a few
// percent, a record that grew a per-run allocation by more. Ratchet the
// cap as the lean-posture work in
// documentation/plans/responsiveness-findings-plan.md lands.
// feedback tables as well.
//
// Baseline, measured 2026-09-24 (M-series, this file under vitest, six
// runs, dist/observe at #3644 + #3646): folded 1.98–2.14x, listened
// 1.91–2.11x, lean 1.71–1.90x (idle ~6.2–7.0ms / 5,000 re-runs; the
// engine ~13–14ms folded). The #3613 engine, re-measured by this
// harness the same day beside this one, was ~2.5–2.8x; its ~9.5–10x
// figure of 2026-09-23 came from a different, flush-per-write
// micro-harness and is not comparable — this harness's numbers are
// the baseline from here. The ratio is harness-sensitive in the other direction too: a
// bare `node` process (no vitest transform in the worker) measures
// idle at ~2.8ms and the folded engine at ~3.1–3.7x, so a faster idle
// build raises the ratio without the engine changing.
//
// Cap 4: ~85–100% headroom over the folded baseline — trips when the
// engine costs roughly double what it does today per re-run, the same
// discipline as observe-idle-cost's 1.25 over a 1.03–1.09 baseline.
// A check that grew a map lookup or a clock read on the hot path
// moves the ratio by a few percent, a record that grew a per-run
// allocation by more; both stay under this until they compound. This
// is the ratchet the #3613 cap (14, against the old ~10) asked for
// once the lean-posture work landed (#3644; see the Lean posture
// section of documentation/plans/responsiveness-findings-plan.md).
for (const fold of FOLDS) await import(fold);
const CAP = 14;
const CAP = 4;
let best = Infinity;
let detail = "";
for (let round = 0; round < 3 && best >= CAP; round++) {
Expand Down
74 changes: 73 additions & 1 deletion packages/signals/tests/attribution-lean-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { attribution, nodeIdOf, registerFold } from "../src/core/attribution.js";
import type { RerunEvent } from "../src/core/attribution.js";
import { createEffect, createRoot, createSignal, flush, getOwner, OBSERVE } from "../src/index.js";
// The point queries register nothing (unlike the folds), so importing them
// here leaves the gate alone.
import { subscriptions, why } from "../src/core/attribution-queries.js";
import {
createEffect,
createMemo,
createRoot,
createSignal,
flush,
getOwner,
OBSERVE
} from "../src/index.js";
import type { RecordListener } from "../src/core/dev.js";

// Channel subscriptions are the consumer's, not the engine's: released here,
Expand Down Expand Up @@ -143,6 +154,67 @@ describe("attribution engine: lean gate", () => {
expect(String(warn.mock.calls[0][0])).toContain("HOT_SCOPE");
});

it("the checks run without a record: wasted recompute still warns, from the frame's facts", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// A memo over a whole object that reads one field: every write to the
// other field re-runs it to the same value.
const [user, setUser] = createSignal({ name: "Ada", visits: 0 }, { name: "user" });
const greeting = createMemo(() => `Hello, ${user().name}`, { name: "greeting" });
createRoot(() =>
createEffect(
() => greeting(),
() => {},
{ name: "header" }
)
);
flush();
attribution.enable({
log: false,
hotRuns: false,
hotTime: false,
wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 0, windowMs: 60_000 }
});
for (let i = 1; i <= 6; i++) {
setUser({ name: "Ada", visits: i });
flush();
}
// `changed`, `selfMs`, `phase`, `at` and the causes came off the frame:
// no record was built, and the finding names the cause all the same.
expect(attribution.history("rerun")).toEqual([]);
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0][0]);
expect(message).toContain("WASTED_RECOMPUTE");
expect(message).toContain('memo "greeting" re-ran 5 times');
expect(message).toContain('Latest cause: "user" (write)');
});

it("why() is a view of the gated buffer; subscriptions() reads the graph", () => {
const [n, setN] = createSignal(0, { name: "n" });
const doubled = createMemo(() => n() * 2, { name: "doubled" });
createRoot(() =>
createEffect(
() => doubled(),
() => {},
{ name: "e" }
)
);
flush();
attribution.enable({ log: false });
setN(1);
flush();
// Nobody wanted a record of that run: nothing to ask `why` about — but
// the dependency view is the graph's, and answers regardless.
expect(why(doubled)).toEqual([]);
expect(subscriptions(doubled)).toEqual(["n"]);

onRerun(() => {});
setN(2);
flush();
// From the moment a consumer appeared: the run it saw, and only that one.
expect(why(doubled).map(e => e.nodeRuns)).toEqual([2]);
expect(subscriptions(doubled)).toEqual(["n"]);
});

// Last: registering a fold is for the rest of the process.
it("a registered fold wants the record", () => {
const folded: RerunEvent[] = [];
Expand Down
Loading