feat(signals)!: one records channel — engine records on OBSERVE.records, attribution.history(type), HoldEvent.silent/.long - #3644
Merged
Conversation
🦋 Changeset detectedLatest commit: 2fbe35c The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Coverage Report for CI Build 36072344672Coverage remained the same at 73.134%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Comparing Footnotes
|
ryansolid
force-pushed
the
records-channel
branch
from
September 24, 2026 23:14
255f8e0 to
2fbe35c
Compare
…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
force-pushed
the
records-channel
branch
from
September 24, 2026 23:21
2fbe35c to
098b55d
Compare
This was referenced Sep 25, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofRecordTypes, declared in@solidjs/signals'dev.tsbeside the channel (soOBSERVE.records.subscribe("rerun", …)types from asolid-jsimport alone, and there is no second interface for host augmenters to merge into). They rideOBSERVE.recordslike every other record;attribution.subscribe(both overloads) andAttributionRecords/AttributionRecordTypeare gone. The live node thatOBSERVE.subjectOf(record)used to look up now arrives beside the record as the listener's second argument:Computedforrerun/create/effect/flight,Computed | undefinedforfallback, the heldSignalforhold,undefinedforflush/interaction/navigation/graph.DiagnosticListenergets 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,emitwalks 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: atry/catchper call (allocation-free unless something throws) reports the throwing listener viaconsole.errorand continues. A type with no listener has no map entry, soobservedis oneMap.has.Lean gate. A
RerunEventis built (dep diff, previews, ring-buffer push, fold, emit, log) only when something wants it:OBSERVE.records.observed("rerun"), a registered fold (costs/feedbackregister on import), orlog. The decision is read atrecomputeStart(frame.prevDeps === nullwhen nothing wants the record — the dep capture was the first cost) and honoured atrecordRerun. 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/effectrecords are gated onobserved(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 noenable({ reruns: true }); a consumer that wants re-run records subscribes torerunor imports a fold, the same "subscribing is what turns them on" the timeline records already had. The contract questions that section listed, answered (pinned inattribution-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'snodeRuns).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 onAttribution.history,RecordTypes,why,subscriptionsand in08-dev-diagnostics.md.OBSERVE.records.subscribe("rerun", …)turns record-building on from the next run start (the gate is read atrecomputeStart, notrecomputeEndas proposed — the dep snapshot is the first cost) and off again on unsubscribe;log: trueand a registered fold do the same. The bareattribution.subscribe(listener)is removed in this PR, so there is no untyped form to gate.checkEffectCycle,checkRelayTear,checkHotRuns,checkHotTime,checkWastedRecompute(frame.start,phase,changed,selfMs, causes) andcheckDepWidthrun before the gate;HOT_SCOPE_RERUNSandWASTED_RECOMPUTEfiring without a record are pinned.@sentry/solid-2dropping itsrerunsubscription 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 andtailMs >= longHolds.infoMs) and carried on the record. The exportedisSilentHold/isLongHoldhelpers are deleted from@solidjs/signals/attributionandsolid-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][]withHistoryRecords = { rerun: RerunEvent; waterfall: WaterfallRecord; hold: HoldEvent; navigation: NavigationEvent; interaction: InteractionEvent }.historyLimitis unchanged. The prod twin returns the same empty array for every type.Public API changes
Removed:
Attribution.subscribe(listener)andAttribution.subscribe(type, listener)—@solidjs/signals/attribution,solid-js/attribution. UseOBSERVE.records.subscribe(type, (event, live) => …).Attribution.history()(no-arg),Attribution.waterfalls(),Attribution.holds(),Attribution.navigations(),Attribution.interactions()— replaced byAttribution.history(type).OBSERVE.subjectOf(record)—@solidjs/signals,solid-js(Observeinterface). The subject is the listener's second argument.isSilentHold(hold),isLongHold(hold)—@solidjs/signals/attribution,solid-js/attribution. Usehold.silent/hold.long.AttributionRecords,AttributionRecordType—@solidjs/signals,@solidjs/signals/attribution,solid-js,solid-js/attribution. UseRecordTypes/RecordType/RecordListener<K>.Added:
RecordTypes(@solidjs/signals,solid-js) gainsrerun,create,effect,flush,flight,fallback,interaction,hold,navigation,graphentries.Attribution.history<K extends HistoryType>(type: K): readonly HistoryRecords[K][]; typesHistoryRecords,HistoryType—@solidjs/signals/attribution,solid-js/attribution.HoldEvent.silent: boolean,HoldEvent.long: boolean.DiagnosticListeneris now(event: DiagnosticEvent, subject: DiagnosticSubject | undefined) => void(OBSERVE.diagnostics.subscribe). Existing one-argument listeners still type-check.Behavior:
attribution.disable()/ releasing the lastenable()hold no longer clears record listeners — they belong to the channel and outlive the engine. Consumers release them with the functionsubscribereturned.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) readshistory("rerun"), so it is empty in the same posture — a consumer that imports neither fold and turnslogoff gets[]from it until something wants the record. It now matches records bynodeIdinstead of asubjectOflookup; a node that has never run under the engine has no id and no history. Signature unchanged.@solidjs/web/performance-tracks:enablePerformanceTrackssubscribes throughOBSERVE.records; no signature change.@solidjs/diagnostics: internal use ofengine.history("rerun")/history("hold"); the bridge's ownholds()is unchanged.Verification
pnpm vitest run— signals: 228 files, 3695 passed / 2 skipped; solid: 37 files, 781 passed (one spec needs@solidjs/universalbuilt); 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. Webtest-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 formatrun over every touched file.attribution.subscribe,subjectOf,isSilentHold,isLongHold,waterfalls(),interactions(),navigations(),AttributionRecordType,AttributionRecords; CHANGELOG.md excluded) returns four intentional lines:dist-artifacts.test.tspinningobserve.subjectOfas 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.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.tsgains multi-throw resume and duplicate-subscribe pins.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.scripts/size, brotli bytes, base = merge-basedcca7d46e): 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,filteron unsubscribe, entry deleted when empty) plus the second listener argument ondiagnostics.emit. Both scenario notes in.size-limit.jsrecord the measurement.Follow-ups
isSilentHold(hold)→hold.silent;attribution.subscribe(…)→OBSERVE.records.subscribe(type, …);attribution.holds()/interactions()/navigations()→attribution.history(type).attribution.subscribe,OBSERVE.subjectOf, the five getters,isSilentHold/isLongHold).installremoval,ownerPath/diagnosticGuideUrlrelocation, type re-export trimming, alias removal, render record, scrub, codes, compilers.