Skip to content

feat(signals)!: one records channel — engine records on OBSERVE.records, attribution.history(type), HoldEvent.silent/.long - #3644

Merged
ryansolid merged 1 commit into
nextfrom
records-channel
Sep 25, 2026
Merged

ryansolid merged 1 commit into
nextfrom
records-channel

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

PR 1 of 4 in the public-API consolidation. Structural only: no new behavior, no new diagnostics. Prerelease (2.0.0-rc.x), so removals ship without aliases or shims.

What changed

One records channel. The attribution engine's ten record types — rerun, create, effect, flush, flight, fallback, interaction, hold, navigation, graph — are now entries of RecordTypes, declared in @solidjs/signals' dev.ts beside the channel (so OBSERVE.records.subscribe("rerun", …) types from a solid-js import alone, and there is no second interface for host augmenters to merge into). They ride OBSERVE.records like every other record; attribution.subscribe (both overloads) and AttributionRecords/AttributionRecordType are gone. The live node that OBSERVE.subjectOf(record) used to look up now arrives beside the record as the listener's second argument: Computed for rerun/create/effect/flight, Computed | undefined for fallback, the held Signal for hold, undefined for flush/interaction/navigation/graph. DiagnosticListener gets the same second argument (subject) so the perf-tracks painter keeps its console task on diagnostic markers without a lookup. OBSERVE.records.observed(type) is the engine's single gate for the timeline records.

Zero per-emit allocation. The channel keeps a copy-on-write listener array per type: subscribe/unsubscribe replace the array, emit walks the current one. A round is a snapshot by construction — a listener unsubscribing mid-delivery is still delivered this round and gone the next, one subscribing mid-delivery hears the next record — with no copy per record. Error isolation stays: a try/catch per call (allocation-free unless something throws) reports the throwing listener via console.error and continues. A type with no listener has no map entry, so observed is one Map.has.

Lean gate. A RerunEvent is built (dep diff, previews, ring-buffer push, fold, emit, log) only when something wants it: OBSERVE.records.observed("rerun"), a registered fold (costs/feedback register on import), or log. The decision is read at recomputeStart (frame.prevDeps === null when nothing wants the record — the dep capture was the first cost) and honoured at recordRerun. The checks (hot runs, hot time, wasted recompute, dep width, relay tear, effect cycle) were decoupled from the event and read the facts (causes, selfMs, at, phase, changed) directly, so they run in every posture. create/effect records are gated on observed(type) at the same sites as before. attribution.history("rerun") is therefore empty when nothing wants records.

Lean posture — the plan's proposal, decided. This implements the lean-posture proposal in documentation/plans/responsiveness-findings-plan.md §Cost ("Lean posture — proposal, needs a decision", landed with #3613). The decision is 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. The contract questions that section listed, answered (pinned in attribution-lean-gate.test.ts):

  • 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's nodeRuns). why(node) is a view of that buffer and shares its gate. subscriptions(node) walks the node's live deps, not a record, and is unaffected. Documented on Attribution.history, RecordTypes, why, subscriptions and in 08-dev-diagnostics.md.
  • OBSERVE.records.subscribe("rerun", …) turns record-building on from the next run start (the gate is read at recomputeStart, not recomputeEnd as proposed — the dep snapshot is the first cost) and off again on unsubscribe; log: true and a registered fold do the same. The bare attribution.subscribe(listener) is removed in this PR, so there is no untyped form to gate.
  • The checks read the frame, not the record: checkEffectCycle, checkRelayTear, checkHotRuns, checkHotTime, checkWastedRecompute (frame.start, phase, changed, selfMs, causes) and checkDepWidth run before the gate; HOT_SCOPE_RERUNS and WASTED_RECOMPUTE firing without a record are pinned.
  • @sentry/solid-2 dropping its rerun subscription is the follow-up in feat(solid-2): add @sentry/solid-2 — Solid 2 SDK (client + server) getsentry/sentry-javascript#24517.

The plan section is rewritten as "Lean posture — LANDED (#3644)" with these answers and the measurements in the follow-up that also ratchets the tripwire cap 14 → 4 (measured 2026-09-24 under vitest, six runs: folded 1.98–2.14×, listened 1.91–2.11×, lean 1.71–1.90×; the section's original 490/55 ns ≈ 9× table was a different flush-per-write micro-harness, so the tripwire's numbers are the baseline going forward).

HoldEvent.silent / HoldEvent.long. Computed once at settle (silent: nothing painted and no acknowledgement; long: long-hold reporting on and tailMs >= longHolds.infoMs) and carried on the record. The exported isSilentHold/isLongHold helpers are deleted from @solidjs/signals/attribution and solid-js/attribution; the engine's own SILENT_HOLD/LONG_HOLD checks and the feedback fold read the fields.

attribution.history(type). history(), waterfalls(), holds(), navigations(), interactions() collapse into one typed accessor over the five ring buffers, history<K extends HistoryType>(type: K): readonly HistoryRecords[K][] with HistoryRecords = { rerun: RerunEvent; waterfall: WaterfallRecord; hold: HoldEvent; navigation: NavigationEvent; interaction: InteractionEvent }. historyLimit is unchanged. The prod twin returns the same empty array for every type.

Public API changes

Removed:

  • Attribution.subscribe(listener) and Attribution.subscribe(type, listener) — @solidjs/signals/attribution, solid-js/attribution. Use OBSERVE.records.subscribe(type, (event, live) => …).
  • Attribution.history() (no-arg), Attribution.waterfalls(), Attribution.holds(), Attribution.navigations(), Attribution.interactions() — replaced by Attribution.history(type).
  • OBSERVE.subjectOf(record) — @solidjs/signals, solid-js (Observe interface). The subject is the listener's second argument.
  • isSilentHold(hold), isLongHold(hold) — @solidjs/signals/attribution, solid-js/attribution. Use hold.silent / hold.long.
  • Types AttributionRecords, AttributionRecordType — @solidjs/signals, @solidjs/signals/attribution, solid-js, solid-js/attribution. Use RecordTypes / RecordType / RecordListener<K>.

Added:

  • RecordTypes (@solidjs/signals, solid-js) gains rerun, create, effect, flush, flight, fallback, interaction, hold, navigation, graph entries.
  • Attribution.history<K extends HistoryType>(type: K): readonly HistoryRecords[K][]; types HistoryRecords, HistoryType — @solidjs/signals/attribution, solid-js/attribution.
  • HoldEvent.silent: boolean, HoldEvent.long: boolean.
  • DiagnosticListener is now (event: DiagnosticEvent, subject: DiagnosticSubject | undefined) => void (OBSERVE.diagnostics.subscribe). Existing one-argument listeners still type-check.

Behavior:

  • attribution.disable() / releasing the last enable() hold no longer clears record listeners — they belong to the channel and outlive the engine. Consumers release them with the function subscribe returned.
  • attribution.history("rerun") is empty while nothing (listener, fold, log) wants re-run records; the run counts and checks are unaffected.
  • why(target) (@solidjs/signals/attribution, solid-js/attribution) reads history("rerun"), so it is empty in the same posture — a consumer that imports neither fold and turns log off gets [] from it until something wants the record. It now matches records by nodeId instead of a subjectOf lookup; a node that has never run under the engine has no id and no history. Signature unchanged.
  • @solidjs/web/performance-tracks: enablePerformanceTracks subscribes through OBSERVE.records; no signature change.
  • @solidjs/diagnostics: internal use of engine.history("rerun") / history("hold"); the bridge's own holds() is unchanged.

Verification

  • pnpm vitest run — signals: 228 files, 3695 passed / 2 skipped; solid: 37 files, 781 passed (one spec needs @solidjs/universal built); web client: 102 files, 944 passed; web server (vite.config.server.mjs): 112 files, 1150 passed / 2 skipped; web hydrate (vite.config.hydrate.mjs): 37 files, 248 passed; diagnostics: 6 files, 34 passed; universal: 3 files, 56 passed. Web test-types (tsconfig.test.json + augment) green; observe.type-tests.ts's declared-catalogue union gains the engine's ten types.
  • pnpm types: 6/6 tasks green. pnpm format run over every touched file.
  • Stale-reference grep (attribution.subscribe, subjectOf, isSilentHold, isLongHold, waterfalls(), interactions(), navigations(), AttributionRecordType, AttributionRecords; CHANGELOG.md excluded) returns four intentional lines: dist-artifacts.test.ts pinning observe.subjectOf as undefined, and three past-tense "as landed" notes in plan docs (observe-tier-plan.md:185,206, chrome-performance-tracks-plan.md:61), each annotated with the replacement.
  • New tests: attribution-lean-gate.test.ts (no record without listener/fold/log; a listener turns it on and off; gate read at run start; log and fold want the record; checks still warn without a record); observe-records.test.ts gains multi-throw resume and duplicate-subscribe pins.
  • feat(signals): WASTED_RECOMPUTE — runs the equality gate discarded #3613 tripwire (attribution-engine-cost.test.ts, dist observe artifacts, best-of-5 interleaved, same machine and hour): base engine ~2.5–2.8x idle; this branch folded+listened ~2.1–2.2x, listened ~2.0–2.3x, lean ~1.8–1.9x. Cap stays at 14 in this PR (the test's historical ~9.5–10x figure came from a different flush-per-write micro-harness and is not comparable); the follow-up ratchets it to 4 against the re-measured baseline above. The tripwire now imports the engine's core module and loads the folds explicitly so all three postures are measured in one process.
  • Size (scripts/size, brotli bytes, base = merge-base dcca7d46e): observe CSR 17677 → 17699 (+22); observe + attribution engine 31686 → 31689 (+3; the tier's +22 is charged in the scenario above, so the engine itself is −19); every other scenario ±0. One cap moved: observe CSR 17.70 → 17.75 KB — the +22 left 1 B under the old ratchet. The +22 is the channel's copy-on-write listener arrays (includes + spread on subscribe, filter on unsubscribe, entry deleted when empty) plus the second listener argument on diagnostics.emit. Both scenario notes in .size-limit.js record the measurement.

Follow-ups

  • feat(solid-2): add @sentry/solid-2 — Solid 2 SDK (client + server) getsentry/sentry-javascript#24517 (Solid integration): isSilentHold(hold) → hold.silent; attribution.subscribe(…) → OBSERVE.records.subscribe(type, …); attribution.holds()/interactions()/navigations() → attribution.history(type).
  • solid-docs needs the same pass over the diagnostics pages (attribution.subscribe, OBSERVE.subjectOf, the five getters, isSilentHold/isLongHold).
  • PR 2–4 of this sequence: install removal, ownerPath/diagnosticGuideUrl relocation, type re-export trimming, alias removal, render record, scrub, codes, compilers.
  • The tripwire's cap (14) is far above today's measured ~2.2x; ratcheted to 4 in the follow-up that records the lean-posture decision in the plan.

@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2fbe35c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
solid-js Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
test-integration Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch

Not sure what this means? Click here to learn what changesets are.

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

@coveralls

coveralls commented Sep 24, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 36072344672

Coverage remained the same at 73.134%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1142
Covered Lines: 883
Line Coverage: 77.32%
Relevant Branches: 894
Covered Branches: 606
Branch Coverage: 67.79%
Branches in Coverage %: Yes
Coverage Strength: 25.14 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing records-channel (098b55d) with next (ed13427)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

…ds, attribution.history(type), HoldEvent.silent/.long

The attribution engine's ten record types (rerun, create, effect, flush,
flight, fallback, interaction, hold, navigation, graph) are RecordTypes
entries and ride OBSERVE.records like every other record; the live node
arrives as the listener's second argument, so attribution.subscribe and
OBSERVE.subjectOf are gone. DiagnosticListener gets the same second
argument. The channel keeps copy-on-write listener arrays per type
(zero allocation per emit, snapshot rounds, per-listener error
isolation). RerunEvent construction is gated on something wanting it
(listener, fold, or log); the checks read the facts directly and run in
every posture. HoldEvent carries silent/long (isSilentHold/isLongHold
removed). history()/waterfalls()/holds()/navigations()/interactions()
collapse into attribution.history(type).

Prerelease: removals ship without aliases. PR 1 of 4 in the public-API
consolidation.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 384a631 into next Sep 25, 2026
8 checks passed
ryansolid added a commit that referenced this pull request Sep 25, 2026
…4 → 4 (#3654)

Closes out the lean-posture proposal in
documentation/plans/responsiveness-findings-plan.md §Cost (landed with
with the gate as shipped — `wantsRerun() = log || folds.length > 0 ||
OBSERVE.records.observed("rerun")`, read at recomputeStart, honoured at
recordRerun — and the four contract questions answered:

- history("rerun") is empty for runs made while nothing wanted a record
  and holds every record from the moment something did; why() is a view
  of that buffer and shares its gate; subscriptions() reads the graph and
  is unaffected. No enable({ reruns: true }) — document, no option.
- OBSERVE.records.subscribe("rerun", …) turns record-building on from the
  next run start and off on unsubscribe; log and a registered fold do the
  same. The bare attribution.subscribe went with #3644.
- The checks read the frame, not the record: checkEffectCycle,
  checkRelayTear, checkHotRuns, checkHotTime, checkWastedRecompute,
  checkDepWidth all run before the gate.
- @sentry/solid-2 dropping its rerun subscription is the follow-up in
  getsentry/sentry-javascript#24517.

JSDoc on why()/subscriptions() and the diagnostics doc lines say the
same; attribution-lean-gate.test.ts pins WASTED_RECOMPUTE without a
record and why()/subscriptions() on a lean engine.

Tripwire (attribution-engine-cost.test.ts) re-measured 2026-09-24 under
vitest, six runs: folded 1.98–2.14x, listened 1.91–2.11x, lean
1.71–1.90x. The section's 490/55 ns ≈ 9x table was a different
flush-per-write micro-harness; the tripwire's numbers are the baseline
from here. Cap 14 → 4 (~85–100% headroom; trips when the engine costs
roughly double what it does today).

Co-authored-by: Claude via Cursor <cursoragent@cursor.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