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
8 changes: 8 additions & 0 deletions .changeset/one-records-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
"@solidjs/diagnostics": patch
---

One records channel: the attribution engine's records (`rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`) are `RecordTypes` entries delivered on `OBSERVE.records.subscribe(type, (event, live) => …)`, with the live node beside each record. Removed `attribution.subscribe` (both overloads), `OBSERVE.subjectOf`, the `AttributionRecords`/`AttributionRecordType` types, and the `isSilentHold`/`isLongHold` helpers — `HoldEvent` now carries `silent` and `long`, computed at settle. `attribution.history()`, `waterfalls()`, `holds()`, `navigations()` and `interactions()` collapse into `attribution.history(type)`. `DiagnosticListener` receives the subject as its second argument. The channel allocates nothing per emit (copy-on-write listener lists), and a `RerunEvent` is built only while a listener, a fold or the log wants it. Record listeners belong to the channel and are no longer dropped by `attribution.disable()`.
5 changes: 3 additions & 2 deletions documentation/plans/chrome-performance-tracks-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ Stages, as landed (one commit each on the branch):
`disable()` is the full teardown; each
`enable()` resets the aggregation windows, which is what a capture wants
— the token form landed in review, replacing a counted `disable()`); `AttributionOptions.checks` (default `true`, D2
proper still open); `isSilentHold`/`isLongHold` on the public entry;
proper still open); `isSilentHold`/`isLongHold` on the public entry (since
replaced by the `HoldEvent.silent`/`.long` fields, stamped at settle);
`dispatchAsInteraction` passes `at: e.timeStamp` and the record carries
`inputDelayMs`; the INP join recipe documented (`entry.startTime ===
interaction.at`).
Expand Down Expand Up @@ -117,7 +118,7 @@ held, interaction? }`. Idle cost: one null-check per drain; the record is
- **`create`** — Known: `recomputeStart(el, create: true)`/`recomputeEnd`
(the existing hooks; previously `recomputeEnd` skipped the record when
`frame.causes === null`). Shape: `RerunEvent` minus causes. Idle cost:
none new; built only while listened to; never enters `history()`/`costs()`.
none new; built only while listened to; never enters `history("rerun")`/`costs()`.
Proof: a memo created inside a render effect's body produces one `create`
record counted in the enclosing `flush.created`.
- **`effect`** — Known: `effectRunStart/End(el)` in `effect.ts`, guard moved
Expand Down
16 changes: 10 additions & 6 deletions documentation/plans/observe-tier-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ byte-identical to today under every bundler.
explicit `ownerPath`, and the server computes its own (P1).
- **D5 — Split, don't extend.** `OBSERVE` = `{ diagnostics: { subscribe,
capture, emit }, attribution: { install, installed, withInteraction },
subjectOf }`; `DEV` = `{ hooks, getChildren, getSignals, getParent,
records: { subscribe, observed, emit } }` (the live node travels as a
listener's second argument, not through a lookup); `DEV` = `{ hooks, getChildren, getSignals, getParent,
getSources, getObservers, report, setConsoleFooter }`.
- **D6 — The engine is an entry, not a member.** `OBSERVE.attribution` is the
core's side only: the hook slot (`install(hooks)`, `installed`) and the
interaction frame (`withInteraction`, which the web runtime calls on every
dispatch and which is `fn()` with no engine installed). The engine —
`enable/disable/history/why/costs/waterfalls/holds/feedback/markFlight/
format/formatOrigin` — is `@solidjs/signals/attribution` (re-exported as
`enable/disable/history(type)/why/costs/feedback/markFlight/
formatRerun/formatOrigin` — is `@solidjs/signals/attribution` (re-exported as
`solid-js/attribution`). Nothing reachable from the core index may import
`core/attribution.ts`. Measured 2026-09-08: with the engine referenced
statically from `OBSERVE.attribution` the observe CSR scenario was 23.79 KB
Expand Down Expand Up @@ -203,9 +204,12 @@ _Status (2026-09-16)._ Landed, in three pieces:
cycle/relay checks key on) names the scope, stable across its runs in the
process and distinct between scopes, so unnamed effects still fold to one
scope offline. `OBSERVE.subjectOf` — the lookup diagnostics already had —
now answers for re-run records too, keyed by the record object for as long
as any consumer holds it (the lifetime the node had when the record carried
it). `@solidjs/diagnostics` stores re-runs verbatim (`RerunRecord` is now
then answered for re-run records too, keyed by the record object for as long
as any consumer held it (the lifetime the node had when the record carried
it); since superseded — the lookup is gone, and the node arrives beside the
record as the listener's second argument (`OBSERVE.records.subscribe("rerun",
(event, live) => …)`, `OBSERVE.diagnostics.subscribe((event, subject) =>
…)`). `@solidjs/diagnostics` stores re-runs verbatim (`RerunRecord` is now
an alias of `RerunEvent`).
- **Clocks: no per-record `ts`.** Every `at` the engine and the runtimes emit
is on the `performance.now()` clock, consistently; a second clock per
Expand Down
21 changes: 13 additions & 8 deletions documentation/plans/responsiveness-findings-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,16 @@ Three facts, in order of weight:

### 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()`. When none holds, keep only what the
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,
Expand All @@ -311,16 +317,15 @@ toward 3–4×; measure before promising.

What it changes, and therefore what to decide:

- `history()`, `why()`, `subscriptions()` on a lean engine return nothing
- `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.
- `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 bare-form `subscribe(fn)`
is the same subscription.
- `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
Expand Down Expand Up @@ -360,8 +365,8 @@ appear, and they shape which fields the records need.
items 1–4 here change, and its Stage 4 `performanceIssue` mapping is
where the findings on this page surface in Chrome's Insights.
- **React DevTools parity checklist**, for the docs and for gap-finding:
"highlight updates" (we have re-run records with `nodeId` → element via
`subjectOf`), "why did this render" (`why()`), owner stacks
"highlight updates" (we have re-run records with `nodeId`, and the live
node as the listener's `live` argument), "why did this render" (`why()`), owner stacks
(`ownerPath`), the `<Profiler>` render durations (`costs().scopes`
self-time). What React DevTools cannot show and we can: holds and their
acknowledgements, the interaction behind a write, the server boundary
Expand Down
22 changes: 12 additions & 10 deletions documentation/proposals/production-observability-sketch.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ build.
### 4.4 Rerun record (from `RerunEvent`)

Serialized as-is: since observe-tier-plan PR B the event carries `nodeId`
instead of the live `node` (`OBSERVE.subjectOf(event)` for in-process
consumers), so `@solidjs/diagnostics`'s `RerunRecord` is the same shape. Attached to the interaction
instead of the live `node` (in-process consumers get the node as the
listener's second argument, `live`), so `@solidjs/diagnostics`'s `RerunRecord` is the same shape. Attached to the interaction
span only above thresholds (4.1); otherwise folded into the span's aggregates.

### 4.5 Cause chain (from `ChangeRecord`)
Expand Down Expand Up @@ -366,13 +366,14 @@ interface ObservabilityAdapter {

Inside `install`, the adapter subscribes to the three feeds:

- `dev.attribution.subscribe(rerun => ...)` — aggregate into the current
interaction span (keyed by `rerun.interaction`), emit rerun children above
thresholds.
- `dev.diagnostics.subscribe(event => ...)` — `warn` → finding (4.3).
- Holds: today only via `dev.attribution.holds()` polling; a `holdEnd`
subscription (`subscribeHolds`) is a small engine addition and should be
made before the first adapter exists rather than after.
- `OBSERVE.records.subscribe("rerun", (rerun, node) => ...)` — aggregate
into the current interaction span (keyed by `rerun.interaction`), emit
rerun children above thresholds.
- `OBSERVE.diagnostics.subscribe((event, subject) => ...)` — `warn` →
finding (4.3).
- Holds: `OBSERVE.records.subscribe("hold", (hold, signal) => ...)` as each
settles (the `holdEnd` subscription this sketch originally asked for);
`attribution.history("hold")` is the ring buffer for polling.

Interaction boundaries: the web runtime's `withInteraction` already brackets
dispatch. The adapter does not wrap events itself — doing so would double-count
Expand All @@ -398,7 +399,8 @@ Solid (this repo):
1. Observe build flavor + export condition for `@solidjs/signals`, `solid-js`,
`@solidjs/web` (§3A). Size scenario and cap for it.
2. `subscribeHolds` on the engine; confirm every feed is subscribable, not
poll-only.
poll-only. (Done: every engine record, `hold` included, is a type on
`OBSERVE.records`.)
3. Component-root labeling in the observe build; compiler `name` emission for
user primitives (already a plan item).
4. Serializable projections as exported types (`RerunRecord` exists in
Expand Down
Loading
Loading