Skip to content

[WIP] "Why did this run" dev attribution (prototype) - #3018

Draft
brenelz wants to merge 4 commits into
solidjs:nextfrom
brenelz:why-did-this-run
Draft

[WIP] "Why did this run" dev attribution (prototype)#3018
brenelz wants to merge 4 commits into
solidjs:nextfrom
brenelz:why-did-this-run

Conversation

@brenelz

@brenelz brenelz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What is this

A prototype answering "why did this scope re-run?" — dev-only observability for the reactive graph, exploring how Solid could make accidental tracking visible (dev attribution), loud (hot/wide-scope warnings), measurable (timings + cost attribution), and checkable (a static rule sketch). Opening as WIP for discussion; API names, defaults, and packaging are all up for debate.

Everything is off by default, dev-build only, and contributes zero bytes to prod bundles (the tree-shake guard from #2883 passes).

The core mechanism

Every value commit stamps its node: a setSignal write, a memo's changed value (with its own upstream causes), an async landing, or a refresh(). When a computation re-runs, its previous dep links are diffed against those stamps — the newer ones are the causes, and they chain to the root write:

[why-run] effect "title-effect" ran (run 2, 0.42ms)
  ← memo "label" changed (#6)
    ← signal "notifications" write (#5) 1 → 2
  deps changed: +"b" -"a" (2 total)

Surface: DEV.attribution.enable({ log, stacks, hotRuns, hotTime, wideDeps }), plus subscribe() (structured RerunEvent stream), history(), why(node), subscriptions(node), costs(), format(event).

Layers on top

  • Hot-scope warnings via the existing diagnostics channel, once per window:
    • HOT_SCOPE_RERUNS — N runs per window (default 60/1000ms), naming the leaking signal.
    • HOT_SCOPE_TIME — self-time budget per window (default 8ms/1000ms), catching the few-but-expensive scope that run counts miss.
  • Wide-scope warningWIDE_SCOPE_DEPS when a scope's dependency count reaches a limit (default 30, creation runs included; re-warns on 50% growth). Store property nodes now carry dev names (store.count) so the evidence reads as paths.
  • Subscription diffingRerunEvent.depsAdded/depsRemoved/depCount per run: when a helper edit changes what a distant scope subscribes to, the next re-run prints it.
  • Timings — per-run selfMs (child-subtracted via a timing stack, so nested recomputes don't double-count) and totalMs. costs() aggregates two ways: by scope (ranked by self-time, with wastedMs = time spent on unchanged-value runs — the recoverable number) and by root write (downstreamMs = total re-run time each write caused — the fan-out cost per write site).
  • Static rule prototypelint/reactive-helpers.ts (TS compiler API; the repo has no eslint, an ESLint rule would wrap this): flags signal/store reads in un-annotated helpers, with fixpoint propagation up the module call graph (the "helper drift" case). Declaration sites: /** @reactive */, a Reactive<...> return type, or being passed to a tracking primitive. Components (PascalCase) exempt.

Implementation notes reviewers may care about

  • Cost when disabled is one boolean check per recompute/write; prod builds fold everything out.
  • rollup tryCatchDeoptimization retains any function referenced inside a try even behind a folded __DEV__ guard — the asyncWrite lane-branch stamp sets a local flag inside the try and stamps after the catch, or the attribution module leaks into prod (~1KB).
  • dev↔attribution import cycle: attribution.ts uses emitDiagnostic; DEV exposes attribution through a getter because under bundler transforms the cyclic binding is undefined (not a TDZ throw) at DEV construction time.
  • Attribution stamps only on committed changes (equality-cutoff runs leave no stamp) so a later unrelated re-run can't mis-attribute.

Tests

25 new tests (16 runtime in tests/attribution.test.ts, 9 static in tests/lint-reactive-helpers.test.ts); solid-signals suite green (1287) including the size guard.

Open questions

  • Naming/shape of DEV.attribution and whether warnings should be on by default in dev.
  • Whether store path names should be hierarchical (todos.0.done) rather than one segment.
  • Where the static rule should live (eslint-plugin-solid vs in-repo) and whether Reactive<T> should be an exported type.

🤖 Generated with Claude Code

Dev-only re-run attribution for the reactive graph, off by default and
fully tree-shaken from prod builds. Every value commit (setSignal,
memo change, async landing, refresh) stamps its node; every recompute
diffs its previous deps against those stamps, producing a causal chain
from the scope that ran down to the root write:

  [why-run] effect "title-effect" ran (run 2, 0.42ms)
    <- memo "label" changed (solidjs#6)
      <- signal "notifications" write (solidjs#5) 1 -> 2

Surfaced as DEV.attribution (enable/disable/subscribe/history/why/
subscriptions/costs/format). Layers on top of the core mechanism:

- Hot-scope warnings: HOT_SCOPE_RERUNS (N runs per window) and
  HOT_SCOPE_TIME (self-time budget per window) via the existing
  diagnostics channel, warned once per window with the leaking
  signal named.
- Wide-scope warning: WIDE_SCOPE_DEPS when a scope's dep count
  reaches a limit (creation runs included) - the coarse-read /
  helper-leak signature. Store property nodes get dev names
  ("store.count") so the evidence reads as paths.
- Subscription diffing: RerunEvent.depsAdded/depsRemoved/depCount
  per run, plus DEV.attribution.subscriptions(node).
- Timings: per-run selfMs (child-subtracted via a timing stack) and
  totalMs; costs() aggregates by scope (with wastedMs = time spent
  on unchanged-value runs) and by root write (downstream fan-out
  cost per write).
- Static prototype: lint/reactive-helpers.ts (TS compiler API) flags
  reactive reads in un-annotated helpers with call-graph propagation;
  declaration sites are @reactive JSDoc, a Reactive<> return type, or
  being passed to a tracking primitive.

Notable integration constraints:
- rollup's tryCatchDeoptimization retains any function referenced
  inside a try block even behind a folded __DEV__ guard, so the
  asyncWrite lane-branch stamp sets a local flag inside the try and
  stamps after the catch (keeps prod at zero attribution bytes).
- dev.ts exposes attribution through a getter: attribution.ts imports
  emitDiagnostic back from dev.ts, and under bundler transforms the
  cyclic binding is undefined (not a TDZ throw) at DEV construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3ddd218

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Aug 19, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 27.33%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 5 regressed benchmarks
✅ 122 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 75.4 µs 172.8 µs -56.36%
merge 220.3 µs 317.7 µs -30.65%
omit 234.2 µs 313.2 µs -25.21%
updateSignals:update1to1 47.8 ms 50.6 ms -5.59%
createComputations:create0to1 36.3 ms 38.3 ms -5.14%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:why-did-this-run (3ddd218) with next (2f5251a)

Open in CodSpeed

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right capability to be building — "why did this run" is the most-asked reactive debugging question there is, and several of the design calls here are exactly right: stamping only committed changes so equality-cutoff runs can't mis-attribute, the wastedMs vs per-write downstreamMs split, and the disabled-cost discipline (the tryCatchDeoptimization and import-cycle notes show the prototype was actually built, not just sketched). Encouraging direction overall. That said, a few things need to change shape before this can land.

1. Transition/lane blindness is the correctness blocker. The stamps don't distinguish an overlay recompute from a committed one. A node recomputing in a transition lane and again at commit records two runs with two timings; an optimistic recompute that lands back on the same committed value gets counted toward wastedMs. For a tool whose entire job is truthful blame, systematically mis-blaming transition and optimistic code — the hardest code to debug, i.e. exactly where people will reach for this — is disqualifying. Runs need a lane/transition tag, and the hot/wasted aggregations need to account for it, before the numbers can be trusted.

2. Extract a hook surface instead of weaving into recompute. ~50 lines through the most safety-critical function in the library is a permanent maintenance tax: every future core change has to keep attribution semantically honest or the tool silently lies (we've touched that exact code twice this month). Follow the GlobalQueue._recomputeLane precedent — a handful of narrow dev-only hook points in core, with all attribution logic living externally. Core's obligation becomes "call the hook with true facts," not "maintain attribution semantics."

3. Default thresholds will cry wolf. HOT_SCOPE_RERUNS at 60/1000ms flags every effect driven by an animation-frame signal — a fully legitimate pattern that lands at exactly 60/window. On the open question: warnings should not be on by default in dev until the false-positive story is proven out; opt-in enable answers it cleanly and costs nothing.

4. The lint rule should move out of this PR entirely. /** @reactive */ and an exported Reactive<T> type are a language-convention commitment, not an observability feature — that deserves its own design discussion in eslint-plugin-solid, where the rule would actually ship. Keeping the prototype in packages/solid-signals/lint/ couples an unsettled convention to this PR's fate.

5. Packaging: decide the devtools relationship early. The structured RerunEvent stream is precisely the substrate solid-devtools wants. If core exposes only the hook surface and the attribution engine lives in a dev module both DEV.attribution and devtools consume, we get one mechanism instead of two competing ones later. That also answers the "where does this live" open question: engine outside core, hooks inside.

Happy to iterate on the hook-point design — the set you need (recompute begin/end with create flag, committed derived change, root write, refresh, async landing) looks small and stable.

Restructure per review feedback:

- Lane/transition truthfulness: recomputeEnd reports optimistic/
  transition/held facts (captured before the lane restore), RerunEvent
  carries phase + held, and wastedMs counts only plain non-held
  unchanged runs. Overlay time is reported separately as
  ScopeCost.overlayMs - an optimistic recompute landing back on the
  committed value is the mechanism working, not waste.

- Hook surface instead of weaving: new attribution-hooks.ts defines
  seven narrow dev-only hook points (recomputeStart/End,
  derivedChanged, write, refreshed, asyncStart/End) with an
  install/uninstall slot, following the GlobalQueue._* precedent.
  Core's obligation is one-line calls with true facts; ALL attribution
  semantics (stamps, cause collection, dep diffing, timing frames,
  warnings, costs) live in the engine, which installs itself on
  enable(). Core no longer imports the engine. The hook surface is
  the intended devtools substrate - one mechanism, DEV.attribution is
  its first consumer.

- Thresholds: hotRuns default raised 60 -> 120/1000ms, deliberately
  above animation-frame cadence so a legitimate rAF-driven scope does
  not cry wolf. All warnings remain opt-in behind enable().

- Lint rule removed from this branch; it is a language-convention
  discussion (eslint-plugin-solid), not an observability feature.

Both dev modules render 0 bytes in prod bundles; suite 1280 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of the refactor commit — this addressed the structural asks properly, not cosmetically.

Hook surface: right. The 7-point AttributionHooks interface with attrHooks !== null call sites is exactly the _recomputeLane shape, core is down to passing raw facts, and codifying the try-block/tryCatchDeoptimization rule in the interface docs means future call-site authors can't re-learn it the hard way.

Phase semantics: right. phase + held on every run, wastedMs restricted to plain/non-held/unchanged runs, overlay time segregated into overlayMs, and the recomputeEnd site capturing posture before the lane restore is the correct moment. That was the correctness blocker and it's genuinely resolved.

One remaining finding: the store node naming is eager. name: __DEV__ ? "store." + String(property) : undefined runs on every store property-node creation in dev builds, engine installed or not — it's the one place the PR breaks its own "one boolean check when disabled" discipline, and it lands on the hottest store path (node creation). Gate it on attrHooks !== null, or better, drop the eager name entirely and derive it lazily in the engine at format time — the engine receives the node, and the property is recoverable from it. (Ignore the current CodSpeed red for now — the runner is having infrastructure issues today — but this one is worth fixing on principle regardless of what the benchmarks say.)

With that, the remaining open questions (engine packaging vs devtools, warning defaults) are genuinely just discussion items — the structure now permits the right answer to each.

brenelz and others added 2 commits August 19, 2026 07:55
Store property-node naming ran on every node creation in dev builds,
engine installed or not - the one place the attribution work broke its
own "one null check when disabled" discipline, on the hottest store
path. Gate the name on attrHooks !== null.

Tradeoff, flagged for review: the property key is NOT recoverable from
the node at format time (it lives only in the node's unobserved()
closure), so fully lazy derivation would need a dev-only field on the
node. With the gate, nodes created before enable() stay generically
named - enable attribution before creating state for full path names.
If late-enable path names matter (attach-to-running-app debugging),
the field is the middle ground: one monomorphic write per store node
creation in dev, concat deferred to format time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store rewrite (solidjs#3019) replaced the legacy store with the
store/next implementation; the attribution-gated store node naming
moves from the legacy getNode to store/next getNode (same semantics:
name only when the engine is installed, one null check otherwise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants