diff --git a/.changeset/one-records-channel.md b/.changeset/one-records-channel.md new file mode 100644 index 000000000..c239bf176 --- /dev/null +++ b/.changeset/one-records-channel.md @@ -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()`. diff --git a/documentation/plans/chrome-performance-tracks-plan.md b/documentation/plans/chrome-performance-tracks-plan.md index 03b931abf..8b4006f37 100644 --- a/documentation/plans/chrome-performance-tracks-plan.md +++ b/documentation/plans/chrome-performance-tracks-plan.md @@ -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`). @@ -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 diff --git a/documentation/plans/observe-tier-plan.md b/documentation/plans/observe-tier-plan.md index ab7222dcc..0bc384fb5 100644 --- a/documentation/plans/observe-tier-plan.md +++ b/documentation/plans/observe-tier-plan.md @@ -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 @@ -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 diff --git a/documentation/plans/responsiveness-findings-plan.md b/documentation/plans/responsiveness-findings-plan.md index 8d51c8aa2..625a4dd54 100644 --- a/documentation/plans/responsiveness-findings-plan.md +++ b/documentation/plans/responsiveness-findings-plan.md @@ -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, @@ -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 @@ -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 `` 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 diff --git a/documentation/proposals/production-observability-sketch.md b/documentation/proposals/production-observability-sketch.md index f9182af2f..8a8e2ed79 100644 --- a/documentation/proposals/production-observability-sketch.md +++ b/documentation/proposals/production-observability-sketch.md @@ -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`) @@ -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 @@ -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 diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 154f75d40..de5d17132 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -430,7 +430,7 @@ Related: `WIDE_SCOPE_DEPS` (below) fires at a much lower threshold, but only whi **Message:** "the live graph grew on 3 consecutive visits to `/orders`: computations 900 → 903 → 906; edges 900 → 903 → 906 (2 roots), across visits to `/orders`, `/`. Each visit left something behind that the next did not reclaim — the shape says an effect or memo created with no owner (a module-level or callback `createEffect`) that only its sources keep alive. Dispose what a visit creates (`onCleanup`, or return the disposer from `onSettled`) and own it under the route's component so leaving the route tears it down." -Attribution-engine only; the leak class a heap snapshot finds, as a finding. At every navigation's settle (`withOrigin({ kind: "navigation" })`) the engine measures the live graph with a **walk**, never a per-node counter: the owner tree from the registered top-level roots (`owners`: roots, component owners, owned computations), then everything reachable from it through the reactive graph — each computation's dependency links (`edges`) and the `signals` and computations they reach, and each reached node's subscriber list, which is how a computation **no owner holds** (a `createEffect` with no owner, kept alive only by the sources it reads) is found and counted in `computations`. It emits a `graph` record (`GraphEvent`: the `GraphSize` fields plus `at`, `route`, `navigation`) for a `subscribe("graph", …)` listener, and keeps the size at each settle of the same route. When any series — `owners`, `computations`, `signals`, `edges` — has climbed on `graphGrowth.visits` consecutive visits (default 3) to `ratio` or more of the first (default 1.25), the route reports, and _which_ series climbed names the leak: `owners` is an undisposed root or a Portal per visit; `computations` with `owners` flat is an ownerless effect; `edges` alone is a subscription per visit to something long-lived. The count is the whole graph's, so a leak shows at every route's settle: the first route to complete its climb reports and `data.routes` names the others seen; the verdict then resets. `data`: `route`, `grew` (the series that climbed), `history` (the `GraphSize` at each settle, oldest first), `roots`, `routes`, `interaction`. No subject. +Attribution-engine only; the leak class a heap snapshot finds, as a finding. At every navigation's settle (`withOrigin({ kind: "navigation" })`) the engine measures the live graph with a **walk**, never a per-node counter: the owner tree from the registered top-level roots (`owners`: roots, component owners, owned computations), then everything reachable from it through the reactive graph — each computation's dependency links (`edges`) and the `signals` and computations they reach, and each reached node's subscriber list, which is how a computation **no owner holds** (a `createEffect` with no owner, kept alive only by the sources it reads) is found and counted in `computations`. It emits a `graph` record (`GraphEvent`: the `GraphSize` fields plus `at`, `route`, `navigation`) for an `OBSERVE.records.subscribe("graph", …)` listener, and keeps the size at each settle of the same route. When any series — `owners`, `computations`, `signals`, `edges` — has climbed on `graphGrowth.visits` consecutive visits (default 3) to `ratio` or more of the first (default 1.25), the route reports, and _which_ series climbed names the leak: `owners` is an undisposed root or a Portal per visit; `computations` with `owners` flat is an ownerless effect; `edges` alone is a subscription per visit to something long-lived. The count is the whole graph's, so a leak shows at every route's settle: the first route to complete its climb reports and `data.routes` names the others seen; the verdict then resets. `data`: `route`, `grew` (the series that climbed), `history` (the `GraphSize` at each settle, oldest first), `roots`, `routes`, `interaction`. No subject. The observe core's part is the root registry: `createOwner` with no parent registers the root (weakly — a `WeakRef`, reaped by a `FinalizationRegistry` — so an undisposed root nothing references still collects; one a subscription keeps alive is exactly what the walk counts), and its disposal unregisters it. One Set write per top-level root, nothing per node; the walk runs at navigation cadence, only when the check is on or something listens for `graph` records. Measured on the observe artifacts (components of 1 signal, 3 memos, 6 effects): 10k owners walk in 0.6ms, 50k in 3.6ms — a `Set` for the reached nodes is most of it. A reactive node the app's own graph never reaches (an ownerless effect over a signal nothing owned reads) is not counted. `graphSize()` is exported from `solid-js/attribution` for a consumer that wants the measure on its own schedule. `false` disables. @@ -466,7 +466,7 @@ The per-cause aggregate of `HOT_SCOPE_RERUNS`. Hot-scope warnings blame the vict Attribution-engine only. An async flight (a promise or async iterable entering the system) formed a sequential chain behind an upstream flight. A chain link is asserted only on double proof: the flight's recompute was **caused** by the upstream's landing (graph causality — create runs inherit the enclosing recompute's causes, which covers boundary reveals and lazy first pulls), and the flight's **origin** post-dates the upstream's landing. Origin is the earliest provable start of the work: an `attribution.markFlight(promise, startedAt)` stamp (preloaders and request caches declaring their kickoff), first-seen object identity, else registration time — so preloaded work already in the air alongside its upstream is parallel and never chains. -The verdict is duration-gated (each link ≥ `waterfalls.minFlightMs`, default 50ms — a settled cache hit resolves fast and never warns). Depth-2 chains emit at `info` severity on the structured channel only: a dependent fetch is sometimes intrinsic, and an _unmarked_ external preload is indistinguishable from a real waterfall, so the console stays quiet. Depth-3+ escalates to a console `warn`. Once per node, re-warning only when the chain grows. Every graph-provable chain — warned or not — is queryable via `attribution.waterfalls()`. +The verdict is duration-gated (each link ≥ `waterfalls.minFlightMs`, default 50ms — a settled cache hit resolves fast and never warns). Depth-2 chains emit at `info` severity on the structured channel only: a dependent fetch is sometimes intrinsic, and an _unmarked_ external preload is indistinguishable from a real waterfall, so the console stays quiet. Depth-3+ escalates to a console `warn`. Once per node, re-warning only when the chain grows. Every graph-provable chain — warned or not — is queryable via `attribution.history("waterfall")`. If a preloading layer hands out wrapper promises (e.g. `.then()` chains over a cached flight), it must call `markFlight` on the wrapper it returns, with the original kickoff time — wrapping defeats identity tracking otherwise. @@ -530,7 +530,7 @@ Thresholds sit at the strict end of the published bands on purpose. The engine m A signal/store write (or an action's writes) was held because a downstream async source went pending, and for the whole hold no acknowledgement was observed: no `isPending()` or `latest()` companion on the held graph that an effect reads (through however many memos — a memo alone is not the screen, so a router's internal `createMemo(() => isPending(location))` counts only once something renders it), no optimistic overlay, no `affects()` declaration, and no lane effect painted while the hold was open. (Mainline effects are stashed while a hold is open, so the only effects that _can_ paint are readers of optimistic values and companions — the screen changing in response to the hold. An unrelated effect cannot clear the verdict; it waits with everything else. A `Loading` boundary that has not revealed yet is a different answer — the read never holds, the fallback shows.) Holds shorter than `holds.infoMs` (default 100ms — RAIL's "feels instant" ceiling) are recorded silently; from `infoMs` the hold emits `info`; from `holds.warnMs` (default 200ms — the INP "good" ceiling) it warns. When the silent hold is also long (below) the message carries the boundary repair and `data.long` is `true`; one hold is one report. -The hold is attributed to its opening interaction when the web runtime can stamp it (`click`, `keydown`, `input` on the element hit), to the effect or action that made the write otherwise. When the held write was a router's navigation declared via `withOrigin`, the hold also carries that `origin` and the message names the route — "[click on a.nav (navigation to /users/:id)] wrote [location] …" — with `data.navigation` giving the pattern, paths and params. `holdMs` runs from the interaction's dispatch or the first parked flush, whichever is earlier (`at` is that instant). Every hold — reported or not — is queryable via `attribution.holds()`; each carries what acknowledged it as `acknowledgements: [{ kind: "isPending", source: "posts", reader: ["", "", "spinner"] }]`, where `reader` is the owner path of the effect the census found painting the affordance — which screen answered, not only that one did. `feedback().sources[].acknowledgedBy` ranks them by `kind:source`. +The hold is attributed to its opening interaction when the web runtime can stamp it (`click`, `keydown`, `input` on the element hit), to the effect or action that made the write otherwise. When the held write was a router's navigation declared via `withOrigin`, the hold also carries that `origin` and the message names the route — "[click on a.nav (navigation to /users/:id)] wrote [location] …" — with `data.navigation` giving the pattern, paths and params. `holdMs` runs from the interaction's dispatch or the first parked flush, whichever is earlier (`at` is that instant). Every hold — reported or not — is queryable via `attribution.history("hold")` and delivered on `OBSERVE.records.subscribe("hold", (event, signal) => …)` as it settles, the held signal beside it; each carries what acknowledged it as `acknowledgements: [{ kind: "isPending", source: "posts", reader: ["", "", "spinner"] }]`, where `reader` is the owner path of the effect the census found painting the affordance — which screen answered, not only that one did. `feedback().sources[].acknowledgedBy` ranks them by `kind:source`. The engine's verdicts are stamped on the record at settle: `HoldEvent.silent` (nothing painted and no acknowledgement — duration-free; `SILENT_HOLD` is this above `holds.infoMs`) and `HoldEvent.long` (the tail reached `longHolds.infoMs`, with long-hold reporting on), so a consumer applies the engine's own tiering rather than a threshold of its own, in-process or offline. #### `LONG_HOLD` @@ -709,12 +709,12 @@ In dev and observe builds, `OBSERVE.diagnostics` provides two methods for toolin ### `OBSERVE.diagnostics.subscribe(listener)` -Registers a callback that fires for every diagnostic event. Returns an unsubscribe function. +Registers a callback that fires for every diagnostic event, with the live node the event is about (when the emitter located one) as its second argument. Returns an unsubscribe function. ```js import { OBSERVE } from "solid-js"; -const unsub = OBSERVE.diagnostics.subscribe(event => { +const unsub = OBSERVE.diagnostics.subscribe((event, subject) => { console.log(`[${event.severity}] ${event.code}: ${event.message}`); }); // later: unsub(); @@ -748,11 +748,11 @@ Each `DiagnosticEvent` has: | `nodeName` | `string?` | Debug name of the signal/node involved | | `data` | `object?` | Additional context | -An event is a serializable record and never carries the node it is about. `OBSERVE.subjectOf(record)` hands the live node back to a consumer that runs in-process — the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. It answers for `DiagnosticEvent`s and the attribution engine's `RerunEvent`s, for as long as the caller holds the record object; a copy that left the process and came back has no subject. +An event is a serializable record and never carries the node it is about. The live node arrives **beside** it instead, as the listener's second argument — `OBSERVE.diagnostics.subscribe((event, subject) => …)` for findings, `OBSERVE.records.subscribe(type, (event, live) => …)` for records — for a consumer that runs in-process: the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. `subject` is `undefined` for a finding with no location (an interaction has no node) or a host finding whose owners are not signals' owners; `live` is what the record type declares (the computation for a re-run, the held signal for a hold, nothing for a flush). A copy of the event that left the process and came back has no subject: the handle was never on it. ### `OBSERVE.records` — the runtimes' records channel -Beside diagnostics (findings) and attribution (re-runs and holds), `OBSERVE` carries **records**: a record is a completed, serializable summary of one thing a runtime did — a boundary that waited, a server-function call, a frame stream — delivered synchronously the moment it is complete, with the live handles an in-process observer may want (the request, the response, the value as thrown) passed **beside** it rather than on it. One channel, `OBSERVE.records`, on both platforms; subscribe by record type, and the types available are whatever the loaded runtimes declared: +Beside diagnostics (findings), `OBSERVE` carries **records**: a record is a completed, serializable summary of one thing a runtime did — a boundary that waited, a server-function call, a frame stream — or the attribution engine saw — a re-run, a hold, an interaction — delivered synchronously the moment it is complete, with the live handles an in-process observer may want (the request, the response, the value as thrown, the node that ran) passed **beside** it rather than on it. One channel, `OBSERVE.records`, on both platforms, for the runtimes' records and the engine's alike; subscribe by record type, and the types available are whatever the loaded runtimes declared: ```js import { OBSERVE } from "solid-js"; @@ -761,9 +761,9 @@ const off = OBSERVE.records.subscribe("invocation", (event, live) => { … }); OBSERVE.records.observed("invocation"); // true while a listener is subscribed — the emitters' pre-check ``` -The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. +The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. Delivery allocates nothing — the channel keeps one listener array per type, replaced (never mutated) on subscribe and unsubscribe, so an emit in progress finishes over the array it started with and a listener unsubscribing mid-delivery neither skips nor double-calls anyone that round. A subscription is the channel's, not any emitter's: it outlives the attribution engine's `enable()`/`disable()` cycles and is dropped only by the function `subscribe` returned. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. -The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares the catalogue empty — `RecordTypes`, extending `HostRecordTypes`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"call"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with every loaded runtime's records from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Five records so far. +The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"call"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Five runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. The **`"boundary"` record** (from `solid-js`, server) is one `` boundary that **waited** during a server render: @@ -831,7 +831,7 @@ const off = OBSERVE.records.subscribe("recovery", (event, live) => { One record per such boundary, delivered when the fresh render has committed. `id` is the boundary's hydration id — the server record's `id`, so the two sides join: the server says how long it tried and why it gave up (`durationMs`, `outcome: "client"`, and the error hook's `handling: "client"` has the error), the client says how long the person looked at the fallback while it did (`waitedMs`, from the boundary registering against the fragment to the rejection reaching it — `0` when the rejection had already arrived at hydration) and what the fresh render cost (`renderMs`). The question they answer together: "this boundary fails on the server 4% of the time and costs users 900ms when it does." Observe-only: prod is byte-identical (the branch and its helper fold), and the clock is read only when something is subscribed. The error itself stays on the server; `live` is empty. -`@solidjs/diagnostics` folds every record type into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v7, one table per type, with `artifact.timeOrigin` anchoring every record's `at` — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README. +`@solidjs/diagnostics` folds the runtimes' record types into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v7, one table per type, with `artifact.timeOrigin` anchoring every record's `at` — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README. ### `OBSERVE.server` — the trace-provider slot @@ -956,20 +956,28 @@ const release = attribution.enable({ checks: true // false: records only — none of the five cost checks above }); -attribution.history(); // ring buffer of RerunEvents -attribution.waterfalls(); // graph-provable sequential flight chains -attribution.holds(); // every hold, acknowledged or not -attribution.navigations(); // every declared navigation, settled or not (below) -attribution.interactions(); // every user interaction, settled or not (below) -attribution.subscribe(fn); // live RerunEvent feed — same as subscribe("rerun", fn) -attribution.subscribe("interaction" | "hold" | "navigation", fn); // each record as it settles -attribution.subscribe("flush" | "create" | "effect" | "flight" | "fallback", fn); // timeline records (below) +// The ring buffers, by type (readonly, oldest first, `historyLimit` deep): +attribution.history("rerun"); // RerunEvents — kept only while a listener, a fold or the log wants them +attribution.history("waterfall"); // graph-provable sequential flight chains +attribution.history("hold"); // every hold, acknowledged or not +attribution.history("navigation"); // every declared navigation, settled or not (below) +attribution.history("interaction"); // every user interaction, settled or not (below) release(); // this consumer's hold; the last release uninstalls attribution.disable(); // everything, whatever holds are outstanding (the console's reset) +// The engine's records are delivered on the core's channel, the same place +// the runtimes' records arrive (`OBSERVE.records`, above): one subscribe, +// typed by record type, the live node beside the record. +import { OBSERVE } from "solid-js"; +const off = OBSERVE.records.subscribe("rerun", (event, node) => { … }); // live RerunEvent feed; `node` is the computation that ran +OBSERVE.records.subscribe("interaction" | "hold" | "navigation", (event, live) => { … }); // each record as it settles; `live` is the held signal for a hold, undefined otherwise +OBSERVE.records.subscribe("flush" | "create" | "effect" | "flight" | "fallback" | "graph", (event, live) => { … }); // timeline records (below) +off(); // subscriptions are the channel's: they outlive enable()/disable() + // Each enable() is a hold and returns its release: a second consumer (a // diagnostics capture beside a profiler track beside an APM adapter) takes -// its own, and listeners and live state survive until the last hold goes. +// its own, and live state survives until the last hold goes (record +// listeners are not the engine's and survive it regardless). // Options combine across holds by the most demanding request per key — the // log prints while any holder wants it, a check runs while any holder wants // it and at the most sensitive threshold asked for, historyLimit is the @@ -1005,7 +1013,6 @@ attribution.markFlight(promise, startedAt?); // writes made synchronously inside `fn` with a user interaction. Compiled // event bindings do this for every handler; custom renderers and test // harnesses call it themselves. `fn()` when no engine is enabled. -import { OBSERVE } from "solid-js"; OBSERVE.attribution.withInteraction({ type: "click", target: 'button#next "Next →"' }, fn); // A router declares a navigation around its location write — match eagerly, // describe by the parametrized route, then write. This is the only @@ -1034,9 +1041,9 @@ createRoot(() => { }); ``` -**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records are serializable as emitted: none carries a live graph reference — a re-run names its scope by `nodeId` (the engine's per-node id, stable across the scope's runs in the process, distinct between scopes; in-process consumers get the node back through `OBSERVE.subjectOf(event)`) — while the frame objects that join records (`origin`, `interaction`) are the same object across records in-process, so join by identity there and by `ChangeOrigin.run`/`at`/`name` once they have left it. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask. +**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records are serializable as emitted: none carries a live graph reference — a re-run names its scope by `nodeId` (the engine's per-node id, stable across the scope's runs in the process, distinct between scopes; in-process consumers get the node itself as the listener's second argument, `live`) — while the frame objects that join records (`origin`, `interaction`) are the same object across records in-process, so join by identity there and by `ChangeOrigin.run`/`at`/`name` once they have left it. `OBSERVE.records.subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it — the same objects `history(type)` keeps. A listener runs inside the engine and must not write signals; hand work off to a microtask. A `RerunEvent` is built — dep diff, previews, cause list, ring-buffer push — only while something wants it: a `rerun` listener, an imported fold (`costs`/`feedback`) or the console log. Without one the engine still keeps its per-node facts and runs every check (hot runs, hot time, wasted recompute, dep width) from them, allocates no record, and `history("rerun")` stays empty. -**Timeline records.** Five further record types describe the work itself rather than its outcome, shaped for a profiler track (`@solidjs/web/performance-tracks`, below) and for the responsiveness findings; they are built only while something is subscribed to them, so an app that does not listen pays nothing beyond the hook null-check. `flush` — one per scheduler drain: `at`, `durationMs`, `runs` and `created` inside it, `held` (a transition parked), `interaction`. `create` — a computation's creation run (`RerunEvent`'s shape without causes: `nodeId`, `nodeName`, `nodeKind`, `at`, `selfMs`, `totalMs`, `depCount`, `phase`, `held`, `interaction`) — the mount work that no `RerunEvent` describes, and what `InteractionEvent.created` counts. `effect` — an effect's callback, the imperative half that writes the DOM: `nodeId`, `nodeName`, `at`, `durationMs`, `run`, `interaction`. `flight` — an async node's flight, kickoff to landing: `nodeId`, `nodeName`, `ownerPath`, `at`, `durationMs`, `outcome: "landed" | "abandoned"` (superseded before it landed), `interaction`. `fallback` — a `` boundary's fallback, displayed to hidden: `ownerPath`, `at`, `shownMs`, `interaction`. `at` is the end of the drain that rendered the swap — the boundary's swap is a staged write that lands with its frame ([RFC 05](05-async-data.md#loading-on-prop-dependencies-that-show-the-fallback-again)), and a swap cleared before that (the content landed before the frame did, or the commit's own sweep cleared it ahead of any effect — the `LOADING_ON_OUTSIDE_HOLD` shape) was never on screen and is no record. Every derived `ChangeRecord` on a re-run's cause chain also carries the `nodeId` of the memo it came from, so a consumer can walk the propagation of one write node by node. +**Timeline records.** Five further record types describe the work itself rather than its outcome, shaped for a profiler track (`@solidjs/web/performance-tracks`, below) and for the responsiveness findings; they enter no ring buffer and are built only while something is subscribed to them (`OBSERVE.records.observed(type)`), so an app that does not listen pays nothing beyond the hook null-check. `flush` — one per scheduler drain: `at`, `durationMs`, `runs` and `created` inside it, `held` (a transition parked), `interaction`. `create` — a computation's creation run (`RerunEvent`'s shape without causes: `nodeId`, `nodeName`, `nodeKind`, `at`, `selfMs`, `totalMs`, `depCount`, `phase`, `held`, `interaction`) — the mount work that no `RerunEvent` describes, and what `InteractionEvent.created` counts. `effect` — an effect's callback, the imperative half that writes the DOM: `nodeId`, `nodeName`, `at`, `durationMs`, `run`, `interaction`. `flight` — an async node's flight, kickoff to landing: `nodeId`, `nodeName`, `ownerPath`, `at`, `durationMs`, `outcome: "landed" | "abandoned"` (superseded before it landed), `interaction`. `fallback` — a `` boundary's fallback, displayed to hidden: `ownerPath`, `at`, `shownMs`, `interaction`. `at` is the end of the drain that rendered the swap — the boundary's swap is a staged write that lands with its frame ([RFC 05](05-async-data.md#loading-on-prop-dependencies-that-show-the-fallback-again)), and a swap cleared before that (the content landed before the frame did, or the commit's own sweep cleared it ahead of any effect — the `LOADING_ON_OUTSIDE_HOLD` shape) was never on screen and is no record. Every derived `ChangeRecord` on a re-run's cause chain also carries the `nodeId` of the memo it came from, so a consumer can walk the propagation of one write node by node. **Joining an interaction to the browser's INP entry.** An `InteractionEvent.at` is the DOM event's `timeStamp` when the runtime dispatched it (compiled event bindings pass it; `OBSERVE.attribution.withInteraction({ …, at: e.timeStamp }, fn)` for a custom dispatcher), and `inputDelayMs` is the queueing from that moment to the handler's entry — the browser's input delay. The `PerformanceEventTiming` entry for the same event (a `PerformanceObserver` on `"event"`) has `startTime === interaction.at` on the same clock and carries `interactionId`, so the join is `entry.startTime === interaction.at`: `inputDelayMs` accounts for the entry's `processingStart − startTime`, `handlerMs` for its processing, and `settledMs` extends past its `duration` to when Solid had the screen right. @@ -1061,7 +1068,7 @@ Every root `ChangeRecord` carries an `origin` describing the imperative frame th Effect- and action-origin writes resolve through the record of the run they belong to, so `RerunEvent.interaction` names the user interaction a re-run ultimately traces to, however many effects relayed it. Writes made after an `await` inside an action's body have left the action's synchronous frame; they are stamped `async`, which is what makes their escape from the transaction visible. -### Navigations (`navigations()`) +### Navigations (`history("navigation")`) A navigation in Solid 2 is a plain write to the location — reads pull the route's async and the runtime holds the write until the data is ready — so the engine already sees everything a navigation costs. What it cannot see is that the writes _were_ a navigation, and to which route. `OBSERVE.attribution.withOrigin({ kind: "navigation", … }, fn)` is where a router says so, around its write; every router (or hand-rolled one) adds that one call, and nothing else anywhere is router-specific. From it the engine keeps one `NavigationEvent` per frame: @@ -1081,14 +1088,14 @@ A navigation that changed nothing (no write survived the equality gate) settles Known gap: handlers bound through the runtime (delegated events, and non-literal `on*` expressions that route through `addEvent`) are stamped; a _literal_ function handler compiles to a bare `addEventListener` and is not, so its writes read as `external` until the compiler wraps them too. -### Interactions (`interactions()`) +### Interactions (`history("interaction")`) -The interaction is the unit a person experiences: one click, and everything it cost until the screen had the answer. Every downstream fact is already keyed to the interaction frame — writes stamp it, re-runs trace to it through their causes, holds and navigations carry it — and `feedback().interactions` folds those by interaction _name_. `interactions()` keeps one `InteractionEvent` per dispatch instead, with a start, an end, and the pieces attached, so a consumer building a span per interaction (an APM adapter) neither infers the end from an idle gap nor sums quantized per-run times to approximate the wall clock: +The interaction is the unit a person experiences: one click, and everything it cost until the screen had the answer. Every downstream fact is already keyed to the interaction frame — writes stamp it, re-runs trace to it through their causes, holds and navigations carry it — and `feedback().interactions` folds those by interaction _name_. `history("interaction")` keeps one `InteractionEvent` per dispatch instead (delivered on `OBSERVE.records.subscribe("interaction", …)` as it settles), with a start, an end, and the pieces attached, so a consumer building a span per interaction (an APM adapter) neither infers the end from an idle gap nor sums quantized per-run times to approximate the wall clock: - `name`, `target`, `at` — what the runtime described to `withInteraction` (`at` the event's own `timeStamp` when it was given, else the dispatch); `inputDelayMs` — the browser's queueing from `at` to the handler's entry, present when `at` predates it; `handlerMs` — the handler itself, entry to return. - `writes` — root writes attributed to the frame: the handler's, and those of frames it opened (a navigation). - `runs` and `created` — re-runs traced back to it, and computations _created_ in those runs or in its flushes (the "create 1,000 rows" work, which no `RerunEvent` describes); `runMs` sums the self-time of both. -- `holds` — the `HoldEvent`s its writes waited in; `navigations` — the `NavigationEvent`s performed under it. The same objects as in `holds()`/`navigations()`. +- `holds` — the `HoldEvent`s its writes waited in; `navigations` — the `NavigationEvent`s performed under it. The same objects as in `history("hold")`/`history("navigation")`. - `settledMs` and `outcome`, once everything is through: `idle` (the handler wrote nothing — settles as the frame closes), `committed` (its writes went through in drains no transition held — settles at the end of the last such drain), `held` (at least one write waited in a transition — settles at the last hold's commit). A navigation under it must settle first. - `origin` — the frame object every downstream record carries as `interaction`; join by identity. diff --git a/packages/diagnostics/README.md b/packages/diagnostics/README.md index a6f80fb8b..cca49fae5 100644 --- a/packages/diagnostics/README.md +++ b/packages/diagnostics/README.md @@ -36,7 +36,7 @@ artifact.attribution; // { reruns, costs, holds, feedback } — who re-ran, why, Options: `scenario` labels the artifact, `attribution: false` captures diagnostics only, and an options object is passed through to the engine's `enable()` (`@solidjs/signals/attribution`); the capture holds the shared engine for its duration and releases only its own hold, so a profiler track or an APM adapter enabled beside it is undisturbed. `artifactToJSONL(artifact)` emits line-oriented output for offline or agent-side analysis. -**Clocks.** Every `at` in the artifact — a re-run's start, a hold's, a record's — is on the capturing process's `performance.now()` clock; `artifact.timeOrigin` (epoch milliseconds, the process's `performance.timeOrigin`) anchors it, so `timeOrigin + at` is the absolute time of anything in the artifact and two captures from one process line up. Durations (`selfMs`, `holdMs`, `durationMs`) are already relative. Re-runs are stored as the engine emits them: `nodeId` names the scope (stable across its runs in the process, distinct between scopes), so runs of unnamed effects still fold to one scope offline; the live node never leaves the process (in-process, `OBSERVE.subjectOf(rerun)` hands it back). +**Clocks.** Every `at` in the artifact — a re-run's start, a hold's, a record's — is on the capturing process's `performance.now()` clock; `artifact.timeOrigin` (epoch milliseconds, the process's `performance.timeOrigin`) anchors it, so `timeOrigin + at` is the absolute time of anything in the artifact and two captures from one process line up. Durations (`selfMs`, `holdMs`, `durationMs`) are already relative. Re-runs are stored as the engine emits them: `nodeId` names the scope (stable across its runs in the process, distinct between scopes), so runs of unnamed effects still fold to one scope offline; the live node never leaves the process (in-process, `OBSERVE.records.subscribe("rerun", (event, node) => …)` hands it over beside the record). ### Records: server renders and browser requests diff --git a/packages/diagnostics/skills/agent-loops/SKILL.md b/packages/diagnostics/skills/agent-loops/SKILL.md index de3dcd357..886babdd7 100644 --- a/packages/diagnostics/skills/agent-loops/SKILL.md +++ b/packages/diagnostics/skills/agent-loops/SKILL.md @@ -129,7 +129,7 @@ measured from the event and keyed by it. A router that wraps its location write in `OBSERVE.attribution.withOrigin({ kind: "navigation", name, to, from, params }, () => …)` names holds by route as well — `SILENT_HOLD` then reads "click on a.nav (navigation to /users/:id) wrote …", and `feedback.navigations` / -`attribution.navigations()` give the per-route view. A redirect declared +`attribution.history("navigation")` give the per-route view. A redirect declared with `redirect: n` folds onto the pending navigation (one record, timed from the click, the abandoned destination in `redirects`) rather than superseding it. diff --git a/packages/diagnostics/src/browser.ts b/packages/diagnostics/src/browser.ts index 7a195de3c..7f2580b7d 100644 --- a/packages/diagnostics/src/browser.ts +++ b/packages/diagnostics/src/browser.ts @@ -127,9 +127,9 @@ export function installDiagnosticsBridge( let attribution: DiagnosticsArtifact["attribution"] = null; if (active.release) { attribution = { - reruns: [...engine.history()], + reruns: [...engine.history("rerun")], costs: costs(), - holds: [...engine.holds()], + holds: [...engine.history("hold")], feedback: feedback() }; active.release(); @@ -150,7 +150,7 @@ export function installDiagnosticsBridge( }, whyDidRun(name) { requireAttributionSession("whyDidRun"); - return toSerializable(engine.history().filter(event => event.nodeName === name)); + return toSerializable(engine.history("rerun").filter(event => event.nodeName === name)); }, costs() { requireAttributionSession("costs"); @@ -158,7 +158,7 @@ export function installDiagnosticsBridge( }, holds() { requireAttributionSession("holds"); - return toSerializable([...engine.holds()]); + return toSerializable([...engine.history("hold")]); }, feedback() { requireAttributionSession("feedback"); diff --git a/packages/diagnostics/src/capture.ts b/packages/diagnostics/src/capture.ts index e371426a6..d10d07bfa 100644 --- a/packages/diagnostics/src/capture.ts +++ b/packages/diagnostics/src/capture.ts @@ -73,9 +73,9 @@ export async function captureArtifact( // Read every table before releasing: the last release resets the aggregates. if (release) { attribution = { - reruns: [...engine.history()], + reruns: [...engine.history("rerun")], costs: costs(), - holds: [...engine.holds()], + holds: [...engine.history("hold")], feedback: feedback() }; release(); diff --git a/packages/diagnostics/src/types.ts b/packages/diagnostics/src/types.ts index cb792058f..030ade07e 100644 --- a/packages/diagnostics/src/types.ts +++ b/packages/diagnostics/src/types.ts @@ -35,7 +35,8 @@ export type AttributionFeedback = AttributionFeedbackTables; /** * A re-run as the artifact stores it. The engine's `RerunEvent` is * serializable as emitted — it names its scope by `nodeId` and never carries - * the live node (in-process consumers ask `OBSERVE.subjectOf(event)`) — so + * the live node (in-process consumers get it as `live` beside the record on + * `OBSERVE.records`) — so * the artifact copies records verbatim; the alias is the artifact's * vocabulary for the same shape. */ diff --git a/packages/signals/src/attribution.prod.ts b/packages/signals/src/attribution.prod.ts index 17c6a2731..5aa88f608 100644 --- a/packages/signals/src/attribution.prod.ts +++ b/packages/signals/src/attribution.prod.ts @@ -16,12 +16,7 @@ const noop = () => {}; export const attribution: Attribution = { enable: () => noop, disable: noop, - subscribe: () => noop, history: () => EMPTY, - waterfalls: () => EMPTY, - holds: () => EMPTY, - navigations: () => EMPTY, - interactions: () => EMPTY, markFlight: noop }; @@ -40,9 +35,6 @@ export const why: typeof Engine.why = () => []; export const subscriptions: typeof Engine.subscriptions = () => []; export const formatRerun: typeof Engine.formatRerun = () => ""; export const formatOrigin: typeof Engine.formatOrigin = () => ""; -// No hold is ever recorded in prod, so no hold is ever silent or long. -export const isSilentHold: typeof Engine.isSilentHold = () => false; -export const isLongHold: typeof Engine.isLongHold = () => false; // Prod registers no roots: the graph has no observable size. export const graphSize: typeof Engine.graphSize = () => ({ roots: 0, @@ -56,8 +48,6 @@ export type { Acknowledgement, Attribution, AttributionOptions, - AttributionRecords, - AttributionRecordType, ChangeKind, ChangeOrigin, ChangeRecord, @@ -70,6 +60,8 @@ export type { GraphEvent, GraphSize, HeldWrite, + HistoryRecords, + HistoryType, HoldEvent, InteractionEvent, NavigationEvent, diff --git a/packages/signals/src/attribution.ts b/packages/signals/src/attribution.ts index 69ead061c..00c06c7c4 100644 --- a/packages/signals/src/attribution.ts +++ b/packages/signals/src/attribution.ts @@ -10,14 +10,7 @@ * `attribution.prod.ts`, an inert engine with the same surface, so app code * can import it unconditionally. */ -export { - attribution, - formatOrigin, - formatRerun, - graphSize, - isLongHold, - isSilentHold -} from "./core/attribution.js"; +export { attribution, formatOrigin, formatRerun, graphSize } from "./core/attribution.js"; // The folds and point queries are named exports, not methods of `attribution`: // each fold registers its accounting with the engine when its module is // evaluated, so a records-only consumer that never imports `costs`/`feedback` @@ -29,8 +22,6 @@ export type { Acknowledgement, Attribution, AttributionOptions, - AttributionRecords, - AttributionRecordType, ChangeKind, ChangeOrigin, ChangeRecord, @@ -43,6 +34,8 @@ export type { GraphEvent, GraphSize, HeldWrite, + HistoryRecords, + HistoryType, HoldEvent, InteractionEvent, NavigationEvent, diff --git a/packages/signals/src/core/attribution-feedback.ts b/packages/signals/src/core/attribution-feedback.ts index 7bfa3d75b..7e5d69919 100644 --- a/packages/signals/src/core/attribution-feedback.ts +++ b/packages/signals/src/core/attribution-feedback.ts @@ -12,8 +12,6 @@ import { FALLBACK_FLASH_MS, formatOrigin, - isLongHold, - isSilentHold, nodeName, now, registerFold, @@ -284,7 +282,7 @@ function recordFeedbackHold(event: HoldEvent): void { feedbackSources.set(key, bucket); } const row = bucket.row; - const silent = isSilentHold(event); + const silent = event.silent; row.holds++; row.heldMs += event.holdMs; if (event.holdMs > row.worstMs) row.worstMs = event.holdMs; @@ -296,7 +294,7 @@ function recordFeedbackHold(event: HoldEvent): void { event.acknowledgements.every(a => a.kind === "latest") ) row.latestOnly++; - if (isLongHold(event)) { + if (event.long) { row.long++; row.longMs += event.tailMs; } @@ -347,7 +345,7 @@ function recordFeedbackNavigation(event: NavigationEvent): void { if (event.outcome === "held") { row.held++; row.heldMs += event.hold?.holdMs ?? ms; - if (event.hold !== undefined && isSilentHold(event.hold)) row.silent++; + if (event.hold !== undefined && event.hold.silent) row.silent++; } } diff --git a/packages/signals/src/core/attribution-queries.ts b/packages/signals/src/core/attribution-queries.ts index c4f19d514..37d543ccd 100644 --- a/packages/signals/src/core/attribution-queries.ts +++ b/packages/signals/src/core/attribution-queries.ts @@ -3,9 +3,8 @@ * one scope. Their own module so a records-only consumer never ships them; * they read the engine's ring buffer and the graph, and register nothing. */ -import { attribution, nodeName, type RerunEvent } from "./attribution.js"; +import { attribution, nodeIdOf, nodeName, type RerunEvent } from "./attribution.js"; import { $REFRESH } from "./constants.js"; -import { subjectOf } from "./dev.js"; import type { Computed } from "./types.js"; /** The node behind a memo/effect accessor, or the raw node passed through. */ @@ -13,10 +12,15 @@ function nodeOf(target: unknown): Computed { return ((target as Record)?.[$REFRESH] ?? target) as Computed; } -/** Re-run history for one node — pass a memo/effect accessor or raw node. */ +/** + * Re-run history for one node — pass a memo/effect accessor or raw node. + * Records name their scope by `nodeId`; a node that has never run under the + * engine has none, and no history. + */ export function why(target: unknown): RerunEvent[] { - const node = nodeOf(target); - return attribution.history().filter(event => subjectOf(event) === node); + const id = nodeIdOf(nodeOf(target)); + if (id === undefined) return []; + return attribution.history("rerun").filter(event => event.nodeId === id); } /** Current dependency names of one scope — the devtools subscription view. */ diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 1bc115622..5b09cf968 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -12,8 +12,8 @@ import { liveRootOwners, isExcluded, isSuppressed, + OBSERVE, ownerPath, - recordSubject, reportDiagnostic } from "./dev.js"; import type { Transition } from "./scheduler.js"; @@ -137,7 +137,8 @@ export interface RerunEvent { * `nodeName` alone would merge every unnamed `effect`). The engine's own * per-node id, also what `ChangeOrigin.run` and the cycle/relay checks * key on; not meaningful across processes or sessions. In-process - * consumers that want the live node ask `OBSERVE.subjectOf(event)`. + * consumers get the live node as the `live` argument beside the record + * (`OBSERVE.records.subscribe("rerun", (event, node) => …)`). */ nodeId: number; /** @@ -195,9 +196,9 @@ export interface RerunEvent { // callback, a scheduler drain, a flight, a fallback. They exist for a consumer // that paints the timeline — a profiler track — where "what ran, when, for how // long" IS the product. None enters a ring buffer or a fold, and none is built -// while nothing is subscribed to its type (`subscribe("create", …)` turns it -// on): a mount storm creates thousands of nodes, and the console/agent reader -// must not pay for records only a timeline wants. +// while nothing is subscribed to its type (`OBSERVE.records.subscribe("create", +// …)` turns it on): a mount storm creates thousands of nodes, and the +// console/agent reader must not pay for records only a timeline wants. /** * A computation's creation run — the first run, the one with no causes to @@ -319,9 +320,9 @@ export interface FallbackEvent { * The live graph's size at a navigation's settle — the moment an app has * finished moving between two screens, so a count that climbs visit after * visit is a root or a subscription the previous screen left behind. - * Delivered on `subscribe("graph", …)` per settled navigation; a walk of the - * owner tree from the registered top-level roots, made only when something - * listens or `graphGrowth` is on. + * Delivered on `OBSERVE.records.subscribe("graph", …)` per settled + * navigation; a walk of the owner tree from the registered top-level roots, + * made only when something listens or `graphGrowth` is on. */ export interface GraphEvent extends GraphSize { /** When the navigation settled (`performance.now()` clock). */ @@ -367,7 +368,7 @@ export interface AttributionOptions { checks?: boolean; /** Capture the user stack frame of each write — slow (default false). */ stacks?: boolean; - /** Ring-buffer size for `history()` (default 200). */ + /** Ring-buffer size for each `history(type)` buffer (default 200). */ historyLimit?: number; /** * Hot-scope warning: emit a diagnostic when one scope re-runs `count` @@ -592,55 +593,32 @@ let options: typeof defaultOptions = { ...defaultOptions }; let history: RerunEvent[] = []; /** - * The records the engine delivers, by `subscribe(type, …)` name. Every one is - * delivered synchronously at the moment it is complete — a re-run when its - * recompute ends, an interaction / hold / navigation when it settles — so a - * consumer never polls the ring buffers to learn that something finished. + * The engine's records go out on the core's channel, `OBSERVE.records` — + * the one every runtime emits on — under the types `RecordTypes` declares + * for it (`rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, + * `interaction`, `hold`, `navigation`, `graph`), each with the live node as + * `live` where the record has one. `records.observed(type)` is the + * pre-check the listener-gated records cost nothing without; the channel + * is process-wide and the subscriptions on it are the consumer's, so the + * engine neither holds nor clears listeners of its own. */ -export interface AttributionRecords { +const records = OBSERVE.records; + +/** + * The ring buffers `attribution.history(type)` reads, by type. Four of the + * five are the channel's records kept since the window opened; `waterfall` + * is a fact the engine keeps but never emits — a graph-provable sequential + * flight chain (`WaterfallRecord`), of which the ASYNC_WATERFALL finding is + * the thresholded view. + */ +export interface HistoryRecords { rerun: RerunEvent; - /** Listener-gated: built only while something is subscribed — see `CreateEvent`. */ - create: CreateEvent; - /** Listener-gated — see `EffectRunEvent`. */ - effect: EffectRunEvent; - /** Listener-gated — see `FlushEvent`. */ - flush: FlushEvent; - /** Listener-gated — see `FlightEvent`. */ - flight: FlightEvent; - /** Listener-gated — see `FallbackEvent`. */ - fallback: FallbackEvent; - interaction: InteractionEvent; + waterfall: WaterfallRecord; hold: HoldEvent; navigation: NavigationEvent; - /** Listener-gated (or on while `graphGrowth` is) — see `GraphEvent`. */ - graph: GraphEvent; -} -export type AttributionRecordType = keyof AttributionRecords; -type RecordListeners = { - [K in AttributionRecordType]: Set<(record: AttributionRecords[K]) => void>; -}; -const recordListeners: RecordListeners = { - rerun: new Set(), - create: new Set(), - effect: new Set(), - flush: new Set(), - flight: new Set(), - fallback: new Set(), - interaction: new Set(), - hold: new Set(), - navigation: new Set(), - graph: new Set() -}; -/** Whether anything listens for `type` — the pre-check the listener-gated records cost nothing without. */ -function listened(type: AttributionRecordType): boolean { - return recordListeners[type].size > 0; -} -function emitRecord(type: K, record: AttributionRecords[K]): void { - for (const listener of recordListeners[type]) listener(record); -} -function clearListeners(): void { - for (const type in recordListeners) recordListeners[type as AttributionRecordType].clear(); + interaction: InteractionEvent; } +export type HistoryType = keyof HistoryRecords; /** @internal The engine's clock: `performance.now()` where it exists. */ export const now: () => number = @@ -654,6 +632,12 @@ interface RunFrame { start: number; childMs: number; causes: ChangeRecord[] | null; // null on create runs + /** + * The dep identities at run entry, for the record's subscription diff — + * captured only when a re-run RECORD is wanted (see `wantsRerun`), so + * `null` on a create run and on a re-run nothing will hear: the engine's + * checks read the facts below, not the record. + */ prevDeps: unknown[] | null; /** Committed value before this run — baseline for the unstable-output check. */ prevValue: unknown; @@ -703,6 +687,24 @@ export function nodeName(node: Signal | Computed): string { return (node as AttributedNode & { _name?: string })._name ?? "anonymous"; } +/** The record vocabulary for a computation's kind: effects carry a `_type`, memos do not. */ +function nodeKind(el: Computed): "effect" | "memo" { + return (el as { _type?: number })._type ? "effect" : "memo"; +} + +/** + * Whether a re-run record has an audience — a listener on the channel, an + * imported fold (`costs`/`feedback`), or the console log. Read at recompute + * START (the subscription diff needs the deps as they were), so a listener + * arriving mid-run hears the next one. Without an audience the engine still + * runs every check and keeps every per-node fact; only the record — its + * cause list copy, dep diff, previews — is not built, and `history("rerun")` + * stays empty. + */ +function wantsRerun(): boolean { + return options.log || folds.length > 0 || records.observed("rerun"); +} + function preview(v: unknown): string { if (v === null) return "null"; switch (typeof v) { @@ -1139,9 +1141,8 @@ function checkDepWidth(el: Computed): void { const node = el as AttributedNode; if (count < limit || count < (node._devWideWarnedAt ?? 0) * 1.5) return; node._devWideWarnedAt = count; - const kind = (el as { _type?: number })._type ? "effect" : "memo"; const message = - `[WIDE_SCOPE_DEPS] ${kind} "${nodeName(el)}" is subscribed to ${count} sources — ` + + `[WIDE_SCOPE_DEPS] ${nodeKind(el)} "${nodeName(el)}" is subscribed to ${count} sources — ` + `it re-runs when any of them change. Narrow its reads or split it into smaller memos. ` + `Sources: ${names.join(", ")}${count > names.length ? ", …" : ""}`; reportDiagnostic( @@ -1184,7 +1185,7 @@ const HOT_FANOUT_FIRST_MILESTONE = 5; * cause chain named so the leaking signal is identified in the message. * Fan-out spam is folded per root cause (see HotCauseWindow above). */ -function checkHotRuns(el: Computed, event: RerunEvent): void { +function checkHotRuns(el: Computed, causes: ChangeRecord[]): void { const cfg = options.hotRuns; if (cfg === false) return; const node = el as AttributedNode; @@ -1201,7 +1202,7 @@ function checkHotRuns(el: Computed, event: RerunEvent): void { // Root-cause key: the set of originating writes behind this scope's latest // re-run. Scopes hot from the SAME roots share one aggregation window. const roots = new Set(); - rootsOf(event.causes, roots); + rootsOf(causes, roots); const causeKey = roots.size > 0 ? [...roots].sort().join(", ") : "(untracked)"; let window = hotCauses.get(causeKey); if (window === undefined || now - window.winStart > cfg.windowMs) { @@ -1212,9 +1213,9 @@ function checkHotRuns(el: Computed, event: RerunEvent): void { window.runs += node._devWinCount; if (window.scopes === 1) { - const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", "); + const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", "); const message = - `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` + + `[HOT_SCOPE_RERUNS] ${nodeKind(el)} "${nodeName(el)}" re-ran ${node._devWinCount} times ` + `in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` + `scope. Latest cause: ${rootCause || "(untracked pull)"}`; reportDiagnostic( @@ -1224,11 +1225,11 @@ function checkHotRuns(el: Computed, event: RerunEvent): void { kind: "perf", severity: "warn", message, - nodeName: event.nodeName, + nodeName: nodeName(el), data: { runs: node._devWinCount, windowMs: cfg.windowMs, - causes: event.causes.map(c => c.name) + causes: causes.map(c => c.name) } }, el @@ -1268,7 +1269,7 @@ function checkHotRuns(el: Computed, event: RerunEvent): void { * few-but-expensive scope: warns when one scope's summed self-time within a * window exceeds the budget. Warned once per window. */ -function checkHotTime(el: Computed, event: RerunEvent): void { +function checkHotTime(el: Computed, selfMs: number, causes: ChangeRecord[]): void { const cfg = options.hotTime; if (cfg === false) return; const node = el as AttributedNode; @@ -1278,12 +1279,12 @@ function checkHotTime(el: Computed, event: RerunEvent): void { node._devTimeWinMs = 0; node._devTimeWarned = false; } - node._devTimeWinMs = (node._devTimeWinMs ?? 0) + event.selfMs; + node._devTimeWinMs = (node._devTimeWinMs ?? 0) + selfMs; if (node._devTimeWarned || node._devTimeWinMs < cfg.budgetMs) return; node._devTimeWarned = true; - const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", "); + const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", "); const message = - `[HOT_SCOPE_TIME] ${event.nodeKind} "${event.nodeName}" spent ` + + `[HOT_SCOPE_TIME] ${nodeKind(el)} "${nodeName(el)}" spent ` + `${node._devTimeWinMs.toFixed(1)}ms of compute inside one ${cfg.windowMs}ms window ` + `(budget ${cfg.budgetMs}ms). Latest cause: ${rootCause || "(untracked pull)"}`; reportDiagnostic( @@ -1293,12 +1294,12 @@ function checkHotTime(el: Computed, event: RerunEvent): void { kind: "perf", severity: "warn", message, - nodeName: event.nodeName, + nodeName: nodeName(el), data: { spentMs: node._devTimeWinMs, budgetMs: cfg.budgetMs, windowMs: cfg.windowMs, - causes: event.causes.map(c => c.name) + causes: causes.map(c => c.name) } }, el @@ -1315,13 +1316,19 @@ function checkHotTime(el: Computed, event: RerunEvent): void { * depends on, or a narrower read. Plain runs only — a held or overlay run * may be replayed and is never blamed as waste. */ -function checkWastedRecompute(el: Computed, event: RerunEvent): void { +function checkWastedRecompute( + el: Computed, + at: number, + phase: RerunEvent["phase"], + changed: boolean, + selfMs: number, + causes: ChangeRecord[] +): void { const cfg = options.wastedRecompute; - if (cfg === false || event.phase !== "plain") return; + if (cfg === false || phase !== "plain") return; // This runs on every re-run: fields on the node (one property read each, // like hotRuns) and the run's own `at` — no clock read, no map lookup. const node = el as AttributedNode; - const at = event.at; if (node._devWasteWinStart === undefined || at - node._devWasteWinStart > cfg.windowMs) { node._devWasteWinStart = at; node._devWasteRuns = 0; @@ -1330,9 +1337,9 @@ function checkWastedRecompute(el: Computed, event: RerunEvent): void { node._devWasteWarned = false; } node._devWasteRuns = node._devWasteRuns! + 1; - if (!event.changed) { + if (!changed) { node._devWasted = node._devWasted! + 1; - node._devWastedMs = node._devWastedMs! + event.selfMs; + node._devWastedMs = node._devWastedMs! + selfMs; } if ( node._devWasteWarned || @@ -1343,9 +1350,9 @@ function checkWastedRecompute(el: Computed, event: RerunEvent): void { return; node._devWasteWarned = true; const win = { runs: node._devWasteRuns, wasted: node._devWasted!, wastedMs: node._devWastedMs! }; - const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", "); + const rootCause = causes.map(c => `"${c.name}" (${c.kind})`).join(", "); const message = - `[WASTED_RECOMPUTE] ${event.nodeKind} "${event.nodeName}" re-ran ${win.runs} times in ` + + `[WASTED_RECOMPUTE] ${nodeKind(el)} "${nodeName(el)}" re-ran ${win.runs} times in ` + `${cfg.windowMs}ms and ${win.wasted} of those produced the same value — ` + `${win.wastedMs.toFixed(1)}ms of compute the equality gate then discarded. Its inputs ` + `change without changing its result: put an equality boundary upstream (a memo over the ` + @@ -1358,13 +1365,13 @@ function checkWastedRecompute(el: Computed, event: RerunEvent): void { kind: "perf", severity: "warn", message, - nodeName: event.nodeName, + nodeName: nodeName(el), data: { runs: win.runs, wasted: win.wasted, wastedMs: win.wastedMs, windowMs: cfg.windowMs, - causes: event.causes.map(c => c.name) + causes: causes.map(c => c.name) } }, el @@ -1381,17 +1388,42 @@ function recordRerun( held: boolean ): void { const causes = frame.causes!; - const prevDeps = frame.prevDeps!; const node = el as AttributedNode; const prevCauses = node._devRunCauses; + const interaction = frame.interaction; if (excludedNode(el)) { // The observer's own computation: keep the per-node bookkeeping its // effect phase reads (see effectRunStart) and record nothing. - node._devRunInteraction = frame.interaction; + node._devRunInteraction = interaction; node._devRunSeq = undefined; node._devRunCauses = causes; return; } + // The facts every run leaves on the node, record or not: the run sequence + // (`ChangeOrigin.run` joins an effect-phase write to it), the run count, + // and what the effect phase inherits — it runs later in the flush with no + // cause list of its own, so it takes this run's interaction and causes + // (see effectRunStart / pushFrame). + const run = ++runSeq; + const nodeRuns = (node._devRunCount = (node._devRunCount ?? 0) + 1); + node._devRunInteraction = interaction; + node._devRunSeq = run; + node._devRunCauses = causes; + noteInteractionRun(interaction, timing.selfMs, false); + if (openFlush !== null) noteFlushRun(openFlush, false, interaction); + // The checks read the facts, not the record, so they run with or without + // an audience for it. + const kind = nodeKind(el); + if (kind === "effect") checkEffectCycle(el, causes); + checkRelayTear(el, causes, prevCauses); + checkHotRuns(el, causes); + checkHotTime(el, timing.selfMs, causes); + checkWastedRecompute(el, frame.start, phase, changed, timing.selfMs, causes); + checkDepWidth(el); + // The record: built only when something wanted it at run start (see + // `wantsRerun`) — a listener, a fold, the log. + const prevDeps = frame.prevDeps; + if (prevDeps === null) return; // Subscription diff: `prevDeps` was captured at run entry; `_deps` now // holds the fresh set. A changed set is the "helper edit changed distant // call sites" signal — surfaced per-event and in the console format. @@ -1403,10 +1435,10 @@ function recordRerun( for (const d of newDeps) if (!prevSet.has(d)) depsAdded.push(nodeName(d as Signal)); for (const d of prevDeps) if (!newSet.has(d)) depsRemoved.push(nodeName(d as Signal)); const event: RerunEvent = { - run: ++runSeq, + run, at: frame.start, - nodeRuns: (node._devRunCount = (node._devRunCount ?? 0) + 1), - nodeKind: (el as { _type?: number })._type ? "effect" : "memo", + nodeRuns, + nodeKind: kind, nodeName: nodeName(el), nodeId: devId(el), causes, @@ -1419,30 +1451,11 @@ function recordRerun( phase, held }; - const interaction = frame.interaction; if (interaction !== undefined) event.interaction = interaction; - // The effect phase runs later in the flush with no cause list of its own: - // it inherits this run's interaction and is joined to this run's causes - // (see effectRunStart / pushFrame). - node._devRunInteraction = interaction; - node._devRunSeq = event.run; - node._devRunCauses = causes; - // The record is serializable and never carries the node; keep the node - // beside it for `OBSERVE.subjectOf` and the engine's own joins (effect - // cycles, relay tears, `why()`), for as long as anyone holds the record. - recordSubject(event, el); history.push(event); if (history.length > options.historyLimit) history.shift(); for (const f of folds) f.rerun?.(el, event); - noteInteractionRun(interaction, timing.selfMs, false); - if (openFlush !== null) noteFlushRun(openFlush, false, interaction); - if (event.nodeKind === "effect") checkEffectCycle(el, causes); - checkRelayTear(el, causes, prevCauses); - checkHotRuns(el, event); - checkHotTime(el, event); - checkWastedRecompute(el, event); - checkDepWidth(el); - emitRecord("rerun", event); + records.emit("rerun", event, el); if (options.log) logRerun(event); } @@ -1500,14 +1513,17 @@ function logRerun(event: RerunEvent): void { } /** - * The engine: turn it on, subscribe to its records, read its ring buffers. - * The folds over those records — `costs()`, `feedback()` — and the point - * queries — `why()`, `subscriptions()` — and the formatters — - * `formatRerun()`, `formatOrigin()` — are named exports of - * `@solidjs/signals/attribution` rather than methods here, so a consumer - * that only wants records (a production adapter) does not ship the tables a - * console or an agent reads; importing a fold is what turns its accounting - * on. + * The engine: turn it on and read its ring buffers. Its records are + * delivered on the core's channel — `OBSERVE.records.subscribe("rerun" | + * "hold" | "interaction" | "navigation" | "create" | "effect" | "flush" | + * "flight" | "fallback" | "graph", (event, live) => …)` — the same place + * the runtimes' records arrive, so a consumer has one subscribe. The folds + * over those records — `costs()`, `feedback()` — and the point queries — + * `why()`, `subscriptions()` — and the formatters — `formatRerun()`, + * `formatOrigin()` — are named exports of `@solidjs/signals/attribution` + * rather than methods here, so a consumer that only wants records (a + * production adapter) does not ship the tables a console or an agent reads; + * importing a fold is what turns its accounting on. */ export interface Attribution { /** @@ -1516,10 +1532,10 @@ export interface Attribution { * profiler track, an APM adapter, a diagnostics capture — so each * `enable()` is a hold: the first installs the hooks and resets * everything; one taken while already enabled opens a fresh window over - * the ring buffers and folds (`history()`, `holds()`, `costs()`, - * `feedback()` … read from here on) without disturbing the live tracking - * state or anyone's subscriptions, so a capture begun beside a running - * consumer still measures only its own scenario. + * the ring buffers and folds (`history(type)`, `costs()`, `feedback()` … + * read from here on) without disturbing the live tracking state or + * anyone's subscriptions, so a capture begun beside a running consumer + * still measures only its own scenario. * * Options combine across holds by the most demanding value per key: a * hold's `opts` say what it wants (the defaults fill what it leaves @@ -1532,73 +1548,51 @@ export interface Attribution { * a console session never silences it, and a capture with tight * thresholds beside a records-only adapter runs the checks for its own * duration. The release is idempotent; the last release uninstalls the - * hooks, clears every ring buffer and drops every subscription. A consumer - * that re-`enable()`s to reopen its window must release both holds (or - * `disable()`). + * hooks and clears every ring buffer. A consumer that re-`enable()`s to + * reopen its window must release both holds (or `disable()`). + * + * Subscriptions are not the engine's: its records arrive on + * `OBSERVE.records`, whose listeners outlive any hold — subscribe before + * or after `enable()`, and unsubscribe with the function `subscribe` + * returned. */ enable(opts?: AttributionOptions): () => void; /** * Tear the engine down whatever holds are outstanding: drops every hold, - * uninstalls the hooks, clears every ring buffer and every subscription. - * The console's and a test harness's reset — a consumer sharing the page - * with others releases its own hold with the function `enable()` returned - * instead. Idempotent; a `disable()` with nothing enabled is a no-op reset. + * uninstalls the hooks and clears every ring buffer. The console's and a + * test harness's reset — a consumer sharing the page with others releases + * its own hold with the function `enable()` returned instead. Idempotent; + * a `disable()` with nothing enabled is a no-op reset. Listeners on + * `OBSERVE.records` are untouched: they are the channel's. */ disable(): void; /** - * Deliver records as they complete — see `AttributionRecords`. The bare - * form is `subscribe("rerun", …)`. Records are the same objects the ring - * buffers hold (`history()`, `interactions()`, `holds()`, `navigations()`), - * delivered synchronously from the engine, so a listener must not write - * signals. Subscriptions survive other consumers' `enable()`/`disable()` - * calls and are dropped only when the last hold is released. + * The ring buffer of `type` since `enable()` — the last `historyLimit` + * records (default 200), oldest first; the same objects the channel + * delivered. Facts, not verdicts: each is recorded regardless of the + * thresholds the findings apply to it. * - * The timeline records — `create`, `effect`, `flush`, `flight`, - * `fallback` — are built only while a listener for their type exists and - * enter no ring buffer; subscribing is what turns them on. They are one - * record per run/callback/drain, so they are for a consumer painting a - * timeline in a session, not for one shipping records off the page. - */ - subscribe(listener: (event: RerunEvent) => void): () => void; - subscribe( - type: K, - listener: (record: AttributionRecords[K]) => void - ): () => void; - history(): readonly RerunEvent[]; - /** - * Every graph-provable sequential flight chain observed since enable() - * (ring-buffered like history()). Facts, not verdicts: chains are recorded - * regardless of the duration gate — the ASYNC_WATERFALL diagnostic is the - * thresholded view of the same data. - */ - waterfalls(): readonly WaterfallRecord[]; - /** - * Every settled transition hold that staged at least one root write since - * enable() (ring-buffered like history()). Facts, not verdicts: recorded - * regardless of duration or acknowledgment — the SILENT_HOLD diagnostic is - * the thresholded, unacknowledged subset. - */ - holds(): readonly HoldEvent[]; - /** - * Every navigation a router declared via `withOrigin` since enable() - * (ring-buffered like history()), settled or not: what route, under which - * interaction, how many writes, and — once its writes are through — how - * long that took and how (`committed` in a plain drain, `held` behind - * route data with the HoldEvent attached, or `superseded` by a later - * navigation before it landed). Facts for any consumer that wants - * navigation spans named by route: no router integration needed. - */ - navigations(): readonly NavigationEvent[]; - /** - * Every user interaction a runtime declared via `withInteraction` since - * enable() (ring-buffered like history()), settled or not: what was - * dispatched, when, what it wrote, the re-runs and creations it caused, - * the holds its writes waited in and the navigations it performed — and, - * once all of that is through, how long the person waited (`settledMs`) - * and how it ended (`idle` / `committed` / `held`). The per-dispatch - * record `feedback().interactions` folds by name. + * - `"rerun"` — every re-run recorded (see `RerunEvent`). Kept only while + * a record had an audience — a `rerun` listener, an imported fold + * (`costs`/`feedback`), or the console log; a records-only consumer + * that wants none of those pays for none, and reads an empty buffer. + * - `"waterfall"` — every graph-provable sequential flight chain, warned + * or not: the ASYNC_WATERFALL finding is the duration-gated view. + * - `"hold"` — every settled transition hold that staged at least one root + * write, acknowledged or not: SILENT_HOLD / LONG_HOLD are the + * thresholded verdicts (`HoldEvent.silent` / `.long` carry them). + * - `"navigation"` — every navigation a router declared via `withOrigin`, + * settled or not: what route, under which interaction, how many writes, + * and — once its writes are through — how long that took and how + * (`committed`, `held` with the `HoldEvent` attached, `superseded`). + * - `"interaction"` — every user interaction a runtime declared via + * `withInteraction`, settled or not: what was dispatched, when, what it + * wrote, the re-runs and creations it caused, the holds its writes + * waited in and the navigations it performed — and, once through, how + * long the person waited (`settledMs`) and how it ended. The + * per-dispatch record `feedback().interactions` folds by name. */ - interactions(): readonly InteractionEvent[]; + history(type: K): readonly HistoryRecords[K][]; /** * Cooperative preload declaration: stamp a flight object (promise or async * iterable) with its true kickoff time BEFORE the reactive graph sees it. @@ -1728,6 +1722,10 @@ function devId(node: object): number { if (id === undefined) devIds.set(node, (id = ++nextDevId)); return id; } +/** @internal The id the engine's records name `node` by, if it has one yet — read without assigning. */ +export function nodeIdOf(node: object): number | undefined { + return devIds.get(node); +} function rootWrites(causes: ChangeRecord[], out: ChangeRecord[]): void { for (const c of causes) { @@ -2219,7 +2217,7 @@ function checkListIdentity( // recompute was CAUSED by flight A's landing: the recompute frame's cause // chain reaches A's "async" ChangeRecord, and that record carries A's own // flight measurement. Chains are facts recorded unconditionally (queryable via -// `waterfalls()`); the ASYNC_WATERFALL diagnostic is the derived verdict. +// `history("waterfall")`); the ASYNC_WATERFALL diagnostic is the derived verdict. // // The sequentiality claim is made over flight ORIGINS, not sightings. The // graph sees a flight when `_inFlight` is assigned, but the underlying work @@ -2330,8 +2328,7 @@ function emitFlight( const path = ownerPath(el); if (path !== undefined) event.ownerPath = path; if (flight.interaction !== undefined) event.interaction = flight.interaction; - recordSubject(event, el); - emitRecord("flight", event); + records.emit("flight", event, el); } function trackFlightStart(el: Computed, flight: object): void { @@ -2343,7 +2340,7 @@ function trackFlightStart(el: Computed, flight: object): void { const superseded = liveFlights.get(el); for (const f of folds) f.flightStart?.(el, superseded !== undefined); if (superseded !== undefined) { - if (listened("flight")) emitFlight(el, superseded, at, "abandoned"); + if (records.observed("flight")) emitFlight(el, superseded, at, "abandoned"); checkAbandonedFlights(el, superseded); } const causes = enclosingCauses(); @@ -2375,7 +2372,7 @@ function finalizeFlight(el: Computed): void { const landedAt = now(); const ms = landedAt - flight.origin; for (const f of folds) f.flightLanded?.(el, ms); - if (listened("flight")) emitFlight(el, flight, landedAt, "landed"); + if (records.observed("flight")) emitFlight(el, flight, landedAt, "landed"); const record = (el as AttributedNode)._devChange; // Only a stamp this landing produced may carry the measurement — a stale // async record from a previous landing must not be re-labeled. @@ -2486,7 +2483,7 @@ export interface HoldEvent { * The declared unit of work the held writes belong to — the `navigation` * a router described via `withOrigin` — when one is known. What names the * hold by route (`navigation to /users/:id`) rather than by signal; the - * same object as `navigations()[].origin`, so the two join by identity. + * same object as `NavigationEvent.origin`, so the two join by identity. */ origin?: ChangeOrigin; /** Flushes that ended with the hold still open. */ @@ -2512,6 +2509,22 @@ export interface HoldEvent { paintedDuringHold: number; /** The hold was opened (or joined) by an `action()`. */ action: boolean; + /** + * The engine's silent-hold verdict, stamped at settle: no affordance + * acknowledged the wait (`acknowledgements` empty) and nothing painted + * while it was open (`paintedDuringHold === 0`). Duration-free — the + * SILENT_HOLD finding is this above `holds.infoMs` — so a consumer can + * flag every silent wait or apply its own floor, and a record that left + * the process still carries the verdict. + */ + silent: boolean; + /** + * The engine's long-hold verdict, stamped at settle: the quiescent tail + * (`tailMs`) reached `longHolds.infoMs` under the options in effect; + * `false` when long-hold tracking is off. The LONG_HOLD finding is this + * verdict, tiered by `warnMs`. + */ + long: boolean; } /** @@ -2744,16 +2757,25 @@ function trackHoldSettled(t: Transition): void { // flush clock keeps the first wait from being forgotten. const at = Math.min(state.start, interaction !== undefined ? interaction.at! : Infinity); const holdMs = end - at; + const tailMs = lastJoinAt === -Infinity ? holdMs : Math.min(holdMs, end - lastJoinAt); + const acknowledgements = [...state.acknowledgements.values()]; + // The verdicts, stamped on the record so a consumer — in-process or + // offline — applies the engine's own tiering rather than a threshold of + // its own: silent is duration-free (the finding adds the floor); long is + // the tail against `longHolds.infoMs` as the options stand at settle. + const longCfg = options.longHolds; const event: HoldEvent = { at, holdMs, - tailMs: lastJoinAt === -Infinity ? holdMs : Math.min(holdMs, end - lastJoinAt), + tailMs, flushes: state.flushes, heldWrites, blockers: [...state.blockers].map(nodeName), - acknowledgements: [...state.acknowledgements.values()], + acknowledgements, paintedDuringHold: state.painted, - action: state.action + action: state.action, + silent: state.painted === 0 && acknowledgements.length === 0, + long: longCfg !== false && longCfg !== undefined && tailMs >= longCfg.infoMs }; if (interaction !== undefined) event.interaction = interaction; if (origin !== undefined) event.origin = origin; @@ -2761,27 +2783,17 @@ function trackHoldSettled(t: Transition): void { if (holdLog.length > options.historyLimit) holdLog.shift(); // Bottom-up delivery: the hold, then the navigations it held, then the // interactions those belong to — each record complete when its parent is. - emitRecord("hold", event); + // `live` is the first held root write's signal: the subject the findings + // below name. + records.emit("hold", event, subject!); checkStackedHolds(t, event, subject!); settleNavigations(t, event); settleInteractionsHeld(t, event); for (const f of folds) f.hold?.(event); - if (isSilentHold(event)) checkSilentHold(event, subject!); + if (event.silent) checkSilentHold(event, subject!); else checkLongHold(event, subject!); } -/** - * The LONG_HOLD verdict on a hold record: its quiescent tail — from the last - * write to join it to the commit — reached `longHolds.infoMs` under the - * options in effect. `false` when long-hold tracking is off. Public so a - * consumer painting or exporting holds applies the engine's own tiering - * rather than a threshold of its own. - */ -export function isLongHold(event: HoldEvent): boolean { - const cfg = options.longHolds; - return cfg !== false && cfg !== undefined && event.tailMs >= cfg.infoMs; -} - function describeHeldWrites(event: HoldEvent): string { return event.heldWrites .map(w => (w.prev !== undefined ? `"${w.name}" (${w.prev} → ${w.value})` : `"${w.name}"`)) @@ -2865,7 +2877,7 @@ function checkSilentHold(event: HoldEvent, subject: Signal): void { `latest(${event.heldWrites[0].name}) to reveal the new input immediately while the data ` + `catches up. The hold itself is correct — do not "fix" this by moving the write off the ` + `async path.`; - const long = isLongHold(event); + const long = event.long; if (long) message += ` ${boundaryRepair(event)}`; const severity = event.holdMs >= cfg.warnMs ? "warn" : "info"; const data = holdData(event); @@ -3043,12 +3055,12 @@ const GRAPH_SERIES: (keyof GraphSize)[] = ["owners", "computations", "signals", function trackGraph(navigation: NavigationEvent): void { const cfg = options.graphGrowth; - if (cfg === false && !listened("graph")) return; + if (cfg === false && !records.observed("graph")) return; const size = graphSize(); const route = navigation.name ?? navigation.to; const event: GraphEvent = { at: now(), ...size, navigation }; if (route !== undefined) event.route = route; - emitRecord("graph", event); + records.emit("graph", event, undefined); if (cfg === false || route === undefined) return; let history = routeCounts.get(route); if (history === undefined) routeCounts.set(route, (history = [])); @@ -3463,7 +3475,7 @@ function noteFlushRun( } function trackFlushStart(): void { - if (!listened("flush")) return; + if (!records.observed("flush")) return; openFlush = { at: now(), runs: 0, created: 0, held: false, interaction: undefined }; } @@ -3516,7 +3528,7 @@ function trackFallback( if (shown) { // Folds count showings too, and the flash finding needs the show's // clock: an open is kept for any of the three audiences. - const record = listened("fallback"); + const record = records.observed("fallback"); if (!record && folds.length === 0 && options.fallbackFlashes === false) return; // The wait is the enclosing recompute's cause's interaction — the read // that registered the pending source runs inside one — else the ambient. @@ -3559,8 +3571,7 @@ function trackFallback( const path = subtree !== undefined ? ownerPath(subtree) : undefined; if (path !== undefined) event.ownerPath = path; if (open.interaction !== undefined) event.interaction = open.interaction; - if (subtree !== undefined) recordSubject(event, subtree); - emitRecord("fallback", event); + records.emit("fallback", event, subtree); } /** flushEnd: the drain's committed swaps have rendered — those fallbacks are on screen from here. */ @@ -3604,7 +3615,7 @@ function trackFlushEnd(): void { held: flush.held }; if (flush.interaction != null) event.interaction = flush.interaction; - emitRecord("flush", event); + records.emit("flush", event, undefined); } drainSeq++; // The swaps this drain committed have rendered. @@ -3627,7 +3638,7 @@ function settleNavigation( event.outcome = outcome; if (hold !== undefined) event.hold = hold; for (const f of folds) f.navigation?.(event); - emitRecord("navigation", event); + records.emit("navigation", event, undefined); trackGraph(event); // The interaction that performed it may have been waiting only on this. const under = openInteractionOf(event.interaction); @@ -3940,7 +3951,7 @@ function maybeSettleInteraction(state: InteractionState, end: number = now()): v } event.settledMs = end - event.at; event.outcome = event.writes === 0 ? "idle" : state.held ? "held" : "committed"; - emitRecord("interaction", event); + records.emit("interaction", event, undefined); } /** The serializable face of a navigation origin for diagnostic `data`. */ @@ -3953,18 +3964,6 @@ function navigationData(origin: ChangeOrigin): Record { return data; } -/** - * The silent-hold shape on a hold record: no affordance acknowledged the wait - * (`acknowledgements` empty) and nothing painted while it was open - * (`paintedDuringHold === 0`). The SILENT_HOLD finding is this verdict above - * `holds.infoMs`; the predicate itself has no threshold, so a consumer can - * flag every silent wait or apply its own floor. Public for the same reason - * as `isLongHold`. - */ -export function isSilentHold(event: HoldEvent): boolean { - return event.paintedDuringHold === 0 && event.acknowledgements.length === 0; -} - // The engine's implementation of the core's dev hook points. Installed by // enable(), uninstalled by disable() — while uninstalled the core pays one // null check per site and nothing else. @@ -3988,7 +3987,9 @@ const engineHooks: AttributionHooks = { start: now(), childMs: 0, causes, - prevDeps: create ? null : captureDeps(el), + // The record's dep diff needs the deps as they were: captured here, + // and only when the record will have an audience (see wantsRerun). + prevDeps: create || !wantsRerun() ? null : captureDeps(el), // Mirror recompute's own prev-value resolution: an earlier run in the // same flush may still be holding in _pendingValue. prevValue: el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value, @@ -4062,10 +4063,10 @@ const engineHooks: AttributionHooks = { // hand it the interaction that built the node (see effectRunStart). (el as AttributedNode)._devRunInteraction = frame.interaction; if (openFlush !== null) noteFlushRun(openFlush, true, frame.interaction); - if (listened("create")) { + if (records.observed("create")) { const event: CreateEvent = { at: frame.start, - nodeKind: (el as { _type?: number })._type ? "effect" : "memo", + nodeKind: nodeKind(el), nodeName: nodeName(el), nodeId: devId(el), depCount: captureDeps(el).length, @@ -4075,8 +4076,7 @@ const engineHooks: AttributionHooks = { held }; if (frame.interaction !== undefined) event.interaction = frame.interaction; - recordSubject(event, el); - emitRecord("create", event); + records.emit("create", event, el); } } markSeen(el); @@ -4126,7 +4126,7 @@ const engineHooks: AttributionHooks = { // The frame's own `at` doubles as the record's start: set only while // listened, so a listener arriving mid-callback finds no start and the // end emits nothing for it — and an unlistened callback pays no clock read. - if (listened("effect")) originFrames[originFrames.length - 1].at = now(); + if (records.observed("effect")) originFrames[originFrames.length - 1].at = now(); }, effectRunEnd(el) { const frame = originFrames[originFrames.length - 1]; @@ -4140,8 +4140,7 @@ const engineHooks: AttributionHooks = { }; if (frame.run !== undefined) event.run = frame.run; if (frame.interaction !== undefined) event.interaction = frame.interaction; - recordSubject(event, el); - emitRecord("effect", event); + records.emit("effect", event, el); } } popFrame("effect"); @@ -4321,7 +4320,6 @@ function uninstall(): void { holds.length = 0; applyOptions(); attributionActive = false; - clearListeners(); resetWindows(); resetTracking(); setAttributionHooks(null); @@ -4349,32 +4347,29 @@ export const attribution: Attribution = { disable() { uninstall(); }, - subscribe( - typeOrListener: AttributionRecordType | ((event: RerunEvent) => void), - listener?: (record: never) => void - ) { - const type = typeof typeOrListener === "string" ? typeOrListener : "rerun"; - const fn = (typeof typeOrListener === "string" ? listener! : typeOrListener) as ( - record: never - ) => void; - const set = recordListeners[type] as Set<(record: never) => void>; - set.add(fn); - return () => set.delete(fn); - }, - history() { - return history; - }, - waterfalls() { - return waterfallLog; - }, - holds() { - return holdLog; - }, - navigations() { - return navigationLog; - }, - interactions() { - return interactionLog; + history(type: HistoryType) { + let buffer: readonly unknown[]; + switch (type) { + case "rerun": + buffer = history; + break; + case "waterfall": + buffer = waterfallLog; + break; + case "hold": + buffer = holdLog; + break; + case "navigation": + buffer = navigationLog; + break; + case "interaction": + buffer = interactionLog; + break; + default: + throw new Error(`attribution.history: unknown record type "${String(type)}"`); + } + // The buffers are typed by their variable; the switch is the proof. + return buffer as readonly never[]; }, markFlight(flight: object, startedAt: number = now()) { // Earliest wins: re-marking (a cache re-serving the same promise) must diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 8f553fc1a..73cb168cd 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -14,6 +14,11 @@ import type { EffectRunEvent, FallbackEvent, FlightEvent, + FlushEvent, + GraphEvent, + HoldEvent, + InteractionEvent, + NavigationEvent, RerunEvent } from "./attribution.js"; // Cycle note: core.ts imports this module; we read its live `context` binding @@ -147,7 +152,18 @@ export interface DiagnosticEvent { data?: Record; } -export type DiagnosticListener = (event: DiagnosticEvent) => void; +/** + * A findings listener. `subject` is the live node the event is about, when + * the emitter located one — passed BESIDE the serializable event, the way + * the records channel passes `live` — for an in-process consumer that goes + * from a finding to the scope (devtools, a console task lookup); + * `undefined` for an event with no location, or a host event whose owners + * are not signals' owners. + */ +export type DiagnosticListener = ( + event: DiagnosticEvent, + subject: DiagnosticSubject | undefined +) => void; export interface DiagnosticCapture { readonly events: readonly DiagnosticEvent[]; @@ -217,16 +233,18 @@ export interface AttributionSlot { } /** - * The records the runtimes deliver on `OBSERVE.records`, by type — each - * entry `{ event, live }`: the serializable record and the live handles - * (a thrown error, a request) an in-process consumer may want beside it. - * The core emits none and declares none; the runtimes that emit declare - * theirs by augmentation, and the union of record types is whatever the - * loaded runtimes declared. `solid-js` augments THIS interface (its - * `"boundary"` record); the runtimes above it — `@solidjs/web`'s - * `"invocation"`, `"frame"` and `"call"`, a router's — augment - * `HostRecordTypes`, reached through the `solid-js` re-export, which this - * interface extends so the channel sees one catalogue. + * The records delivered on `OBSERVE.records`, by type — each entry + * `{ event, live }`: the serializable record and the live handle (the node + * that ran, a thrown error, a request) an in-process consumer may want + * beside it. This package declares the attribution engine's records here — + * the engine ships in this package, behind its own entry, and emits on the + * same channel as every runtime — and the runtimes that emit declare theirs + * by augmentation, so the union of record types is whatever loaded. + * `solid-js` augments THIS interface (its `"boundary"` and `"recovery"` + * records); the runtimes above it — `@solidjs/web`'s `"invocation"`, + * `"frame"` and `"call"`, a router's — augment `HostRecordTypes`, reached + * through the `solid-js` re-export, which this interface extends so the + * channel sees one catalogue. * * Two interfaces, one augmenter each, by design: TypeScript merges an * augmentation into a re-exported interface by following the alias, and @@ -234,8 +252,37 @@ export interface AttributionSlot { * (`"@solidjs/signals"` from solid-js, `"solid-js"` from web) merge * order-dependently — one set is lost. So each layer augments an interface * of its own, through one module name. + * + * The engine's records (`@solidjs/signals/attribution`; none is emitted + * until `attribution.enable()`): `live` is the computation the record is + * about where there is one — the node that ran for `rerun`, `create` and + * `effect`, the async node for `flight`, the boundary's subtree for + * `fallback` (when the boundary reported one), the first held root signal + * for `hold` (the subject the SILENT_HOLD finding names) — and `undefined` + * for the records with no single subject (`flush`, `interaction`, + * `navigation`, `graph`). The records are the same objects the engine's + * ring buffers hold (`attribution.history(type)`), delivered synchronously + * the moment each is complete — a re-run at recompute end, a hold, a + * navigation, an interaction when it settles, bottom-up — so a listener + * runs inside the engine and must not write signals. The timeline records + * (`create`, `effect`, `flush`, `flight`, `fallback`) and `graph` enter no + * ring buffer and are built only while `observed(type)`: subscribing is + * what turns them on. `rerun` is built while something wants it — a + * listener, an imported fold (`costs`/`feedback`) or the console log; the + * engine's own checks read the facts, not the record. */ -export interface RecordTypes extends HostRecordTypes {} +export interface RecordTypes extends HostRecordTypes { + rerun: { event: RerunEvent; live: Computed }; + create: { event: CreateEvent; live: Computed }; + effect: { event: EffectRunEvent; live: Computed }; + flush: { event: FlushEvent; live: undefined }; + flight: { event: FlightEvent; live: Computed }; + fallback: { event: FallbackEvent; live: Computed | undefined }; + interaction: { event: InteractionEvent; live: undefined }; + hold: { event: HoldEvent; live: Signal }; + navigation: { event: NavigationEvent; live: undefined }; + graph: { event: GraphEvent; live: undefined }; +} /** The record types host runtimes declare — see `RecordTypes`. */ export interface HostRecordTypes {} @@ -255,13 +302,15 @@ export type RecordListener = ( * consumer (an APM adapter's `init()`, devtools, the diagnostics harness) * subscribes to the completed, serializable summaries of the things the * runtimes did — a `` boundary that waited on the server, a - * server-function execution or call, a frame stream produced or applied — - * each delivered synchronously the moment it is complete, with its live - * handles passed BESIDE it. Any number of listeners; none can alter what it - * observes; one that throws is reported and the rest run. (Reactive - * attribution — re-runs, holds, interactions — is the attribution engine's - * `subscribe`, a separate entry the observe build pays for only when - * imported.) + * server-function execution or call, a frame stream produced or applied, + * and the attribution engine's: a re-run, a hold, an interaction — each + * delivered synchronously the moment it is complete, with its live handle + * passed BESIDE it. Any number of listeners; none can alter what it + * observes; one that throws is reported and the rest run. The engine's + * records are declared here and emitted only while the engine + * (`@solidjs/signals/attribution`, a separate entry the observe build pays + * for only when imported) is enabled; `observed(type)` is the one gate an + * emitter of either kind checks before building a record. * * The object is created once per PROCESS under a registered symbol, so a * subscription made before the emitting runtime has loaded, or from a @@ -269,7 +318,12 @@ export type RecordListener = ( * prod with the rest of `OBSERVE`. */ export interface Records { - /** Deliver `type` records as they complete; returns the unsubscribe. */ + /** + * Deliver `type` records as they complete; returns the unsubscribe. The + * subscription is the channel's, not any emitter's: it outlives the + * attribution engine's `enable()`/`disable()` cycles and is dropped only + * by its own unsubscribe. + */ subscribe(type: K, listener: RecordListener): () => void; /** * Whether anything is subscribed to `type` — an emitter's pre-check, so @@ -278,8 +332,10 @@ export interface Records { observed(type: RecordType): boolean; /** * Delivers a completed record to `type`'s listeners, synchronously: how a - * runtime publishes. Snapshot iteration — a listener unsubscribing - * mid-delivery neither skips nor double-calls anyone this round. + * runtime publishes. Snapshot semantics without a snapshot — the listener + * list is replaced, never mutated, on subscribe/unsubscribe — so a + * listener unsubscribing mid-delivery neither skips nor double-calls + * anyone this round, and delivery allocates nothing. */ emit(type: K, event: RecordEvent, live: RecordLive): void; } @@ -319,27 +375,6 @@ export interface Observe { attribution: AttributionSlot; /** The server runtime's surface — see `ServerObserve`. */ server: ServerObserve; - /** - * The live node an emitted record was about, when the emitter knew it. - * Records are serializable and never carry the node — a diagnostic event - * names its subject by `ownerPath`/`nodeName`, a re-run record by - * `nodeId` — so consumers that run in-process (devtools, the console - * reporter, `subscriptions(OBSERVE.subjectOf(run))`) look the - * node up here. Answers for `DiagnosticEvent`s and the attribution - * engine's node records — `RerunEvent`, `CreateEvent`, `EffectRunEvent`, - * `FlightEvent`, and a `FallbackEvent` whose boundary reported its - * subtree; `undefined` for anything else, and for a record that has left - * the process and come back. - */ - subjectOf( - record: - | DiagnosticEvent - | RerunEvent - | CreateEvent - | EffectRunEvent - | FlightEvent - | FallbackEvent - ): DiagnosticSubject | undefined; /** * Marks `owner`'s subtree as the observer's own. A consumer that renders * inside the app it watches — an APM adapter's panel, devtools — would @@ -439,34 +474,48 @@ const attributionSlot: AttributionSlot = { // holds two of this module, and a listener installed through one must hear // the records the render emits through the other. The registered key makes // every copy find the one listener set; the object is generic — a Map of -// type to listener set — and carries no knowledge of the records. +// type to listener list — and carries no knowledge of the records. +// +// Delivery is the hot path: the attribution engine emits a `rerun` record +// per recompute through here, so `emit` must allocate nothing. The listener +// list per type is COPY-ON-WRITE — `subscribe`/unsubscribe replace the +// array, never mutate it — so the array `emit` picked up is a snapshot by +// construction: a listener unsubscribing (itself or another) mid-delivery +// is still delivered to this round and skipped from the next, one +// subscribing mid-delivery hears the next record, and no copy is made per +// record. Subscriptions are rare; a copy there is free. A type with no +// listener has no entry, so `observed` is one `has`. +type AnyRecordListener = (event: unknown, live: unknown) => void; const RECORDS = Symbol.for("@solidjs/signals/observe/records"); function recordsChannel(): Records { const g = globalThis as { [RECORDS]?: Records }; if (g[RECORDS]) return g[RECORDS]; - const listeners = new Map>(); + const listeners = new Map(); return (g[RECORDS] = { - subscribe(type: string, listener: Function) { - let set = listeners.get(type); - if (!set) listeners.set(type, (set = new Set())); - set.add(listener); + subscribe(type: string, listener: AnyRecordListener) { + const current = listeners.get(type); + // Set semantics: one entry per function, however often it is passed. + if (current === undefined) listeners.set(type, [listener]); + else if (!current.includes(listener)) listeners.set(type, [...current, listener]); return () => { - set!.delete(listener); + const list = listeners.get(type); + if (list === undefined) return; + const next = list.filter(l => l !== listener); + if (next.length > 0) listeners.set(type, next); + else listeners.delete(type); }; }, observed(type: string) { - const set = listeners.get(type); - return set !== undefined && set.size > 0; + return listeners.has(type); }, emit(type: string, event: unknown, live: unknown) { - const set = listeners.get(type); - if (set === undefined || set.size === 0) return; - // Snapshot: a listener unsubscribing (itself or another) mid-delivery - // must not skip or double-call anyone this round. A throwing listener - // is reported; the others, and what was observed, are unaffected. - for (const listener of [...set]) { + const list = listeners.get(type); + if (list === undefined) return; + // A throwing listener is reported and the rest still hear the record. + // The try/catch per call allocates nothing unless something throws. + for (let i = 0; i < list.length; i++) { try { - listener(event, live); + list[i](event, live); } catch (error) { console.error(error); } @@ -484,9 +533,6 @@ export const OBSERVE: Observe = __OBSERVE__ // client the slot stays this placeholder. The cast: the interface is // empty HERE and gains its members by augmentation downstream. server: {} as ServerObserve, - subjectOf(record) { - return eventSubjects.get(record); - }, exclude(owner) { excludedOwners.add(owner); hasExclusions = true; @@ -610,8 +656,9 @@ export function emitDiagnostic( const path = ownerPath(subject); if (path) entry.ownerPath = path; } - if (subject) eventSubjects.set(entry, subject); - for (const listener of diagnosticListeners) listener(entry); + const live = subject ?? undefined; + if (live !== undefined) eventSubjects.set(entry, live); + for (const listener of diagnosticListeners) listener(entry, live); for (const capture of diagnosticCaptures) capture.push(entry); // Footer for events that never reach reportDiagnostic because the call site // throws the message instead (every such site is severity "error"): a @@ -637,28 +684,16 @@ function takeFooter(entry: DiagnosticEvent): string | undefined { } /** - * The subject each emitted event was about: events are serializable records - * and cannot carry the node, so the node is kept beside the record for the - * in-process consumers that want it — the console step, which can show what - * the node knows (a rendering runtime may stamp a binding effect with the DOM - * element it writes, `_devElement`, and a live element reference beside the - * message is the most addressable pointer a console can print), and devtools - * that go from a re-run record back to the scope that ran. Keyed by the - * record object, so the subject lives exactly as long as some consumer holds - * the record (a ring buffer, a captured artifact) — the same lifetime the - * node had when records carried it directly. + * The subject each emitted event was about, for the console step, which + * runs after `emitDiagnostic` returned and can show what the node knows: a + * rendering runtime may stamp a binding effect with the DOM element it + * writes (`_devElement`), and a live element reference beside the message + * is the most addressable pointer a console can print. Listeners get the + * subject as their second argument instead; the map exists only to carry + * it from `emitDiagnostic` to `reportDiagnostic` across the call site's + * `reportDiagnostic(emitDiagnostic(…))`. Weak, keyed by the entry. */ -const eventSubjects = new WeakMap(); - -/** Register `subject` as what `record` was about — see `Observe.subjectOf`. */ -export function recordSubject(record: object, subject: DiagnosticSubject): void { - eventSubjects.set(record, subject); -} - -/** The live subject `record` was about, if its emitter registered one. */ -export function subjectOf(record: object): DiagnosticSubject | undefined { - return eventSubjects.get(record); -} +const eventSubjects = new WeakMap(); /** * The console face of a diagnostic — ONE entry per finding: the message, the diff --git a/packages/signals/tests/attribution-benchmark-eval.test.ts b/packages/signals/tests/attribution-benchmark-eval.test.ts index 2a2692f77..e9fa484a1 100644 --- a/packages/signals/tests/attribution-benchmark-eval.test.ts +++ b/packages/signals/tests/attribution-benchmark-eval.test.ts @@ -19,10 +19,18 @@ import { flush, OBSERVE } from "../src/index.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; import type { RerunEvent } from "../src/core/attribution.js"; +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} + afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -38,7 +46,7 @@ function arm(opts: Parameters[0] = {}) { const diagnostics: DiagnosticEvent[] = []; OBSERVE!.diagnostics.subscribe(e => diagnostics.push(e)); const reruns: RerunEvent[] = []; - attribution.subscribe(e => reruns.push(e)); + on("rerun", e => reruns.push(e)); return { diagnostics, reruns }; } diff --git a/packages/signals/tests/attribution-current-origin.test.ts b/packages/signals/tests/attribution-current-origin.test.ts index 44969a452..503810b4c 100644 --- a/packages/signals/tests/attribution-current-origin.test.ts +++ b/packages/signals/tests/attribution-current-origin.test.ts @@ -23,11 +23,20 @@ import { } from "../src/index.js"; import type { ChangeOrigin, InteractionEvent, NavigationEvent } from "../src/core/attribution.js"; import type { NavigationRef } from "../src/core/attribution-hooks.js"; +import type { RecordListener, RecordType } from "../src/core/dev.js"; const INSTALLED = Symbol.for("@solidjs/signals/observe/attribution"); const current = () => OBSERVE!.attribution.currentOrigin(); +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} + afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -39,8 +48,8 @@ function arm() { attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false }); const interactions: InteractionEvent[] = []; const navigations: NavigationEvent[] = []; - attribution.subscribe("interaction", e => interactions.push(e)); - attribution.subscribe("navigation", e => navigations.push(e)); + on("interaction", e => interactions.push(e)); + on("navigation", e => navigations.push(e)); return { interactions, navigations }; } diff --git a/packages/signals/tests/attribution-effect-cycle.test.ts b/packages/signals/tests/attribution-effect-cycle.test.ts index d1f801806..047a76d67 100644 --- a/packages/signals/tests/attribution-effect-cycle.test.ts +++ b/packages/signals/tests/attribution-effect-cycle.test.ts @@ -16,9 +16,17 @@ import { OBSERVE } from "../src/index.js"; import type { RerunEvent } from "../src/core/attribution.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -221,7 +229,7 @@ describe("EFFECT_WRITES_OWN_SOURCE", () => { it("stamps effect-origin writes with the run whose effect phase made them", () => { arm(); const runs: RerunEvent[] = []; - attribution.subscribe(e => runs.push(e)); + on("rerun", e => runs.push(e)); const [n, setN] = createSignal(0, { name: "n" }); const [out, setOut] = createSignal(0, { name: "out" }); createRoot(() => { diff --git a/packages/signals/tests/attribution-engine-cost.test.ts b/packages/signals/tests/attribution-engine-cost.test.ts index 60990db47..9f85cbaa0 100644 --- a/packages/signals/tests/attribution-engine-cost.test.ts +++ b/packages/signals/tests/attribution-engine-cost.test.ts @@ -9,7 +9,17 @@ * Relative tripwire, same discipline as observe-idle-cost: the SAME workload * runs on the built observe artifact with the engine idle and with it * enabled (defaults, log off), interleaved, best-of-k, and the enabled/idle - * ratio is what is capped. + * ratio is what is capped. Three enabled postures, cheapest first: + * + * - lean — nobody wants re-run records (no listener, fold or log): the engine + * runs its checks off the raw facts and builds no `RerunEvent`; + * - 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. + * + * 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. */ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -17,11 +27,14 @@ import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; type Tier = typeof import("../src/index.js"); -type Engine = typeof import("../src/attribution.js"); +type Engine = typeof import("../src/core/attribution.js"); const here = dirname(fileURLToPath(import.meta.url)); const OBSERVE = resolve(here, "../dist/observe/index.js"); -const ENGINE = resolve(here, "../dist/observe/attribution.js"); +const ENGINE = resolve(here, "../dist/observe/core/attribution.js"); +const FOLDS = ["attribution-costs", "attribution-feedback"].map(m => + resolve(here, `../dist/observe/core/${m}.js`) +); /** * N chains of signal → memo → memo → effect, half of whose first memo @@ -74,38 +87,68 @@ describe.skipIf(!existsSync(OBSERVE) || !existsSync(ENGINE))("attribution engine try { const N = 500; const K = 10; + const records = tier.OBSERVE!.records; + /** The engine enabled with defaults; `listened` adds a no-op `rerun` subscriber. */ + const enabled = (listened: boolean): number => { + const off = listened ? records.subscribe("rerun", () => {}) : () => {}; + const release = attribution.enable({ log: false }); + try { + return workload(tier, N, K); + } finally { + release(); + off(); + } + }; + /** Best-of-5 of the idle workload against one enabled posture. */ + const measure = (listened: boolean) => { + let idleMs = Infinity; + let enabledMs = Infinity; + for (let i = 0; i < 5; i++) { + idleMs = Math.min(idleMs, workload(tier, N, K)); + enabledMs = Math.min(enabledMs, enabled(listened)); + } + return { + ratio: enabledMs / idleMs, + detail: `${(enabledMs / idleMs).toFixed(2)}x (${enabledMs.toFixed(1)}ms / idle ${idleMs.toFixed(1)}ms)` + }; + }; workload(tier, N, 2); - const warmRelease = attribution.enable({ log: false }); - workload(tier, N, 2); - warmRelease(); - // Measured 2026-09-23 (M-series, engine at #3613): ~9.5–10x — the - // RerunEvent (causes, dep diffs, previews), the history ring buffer, + enabled(true); + + // Nobody listening: no RerunEvent, no ring-buffer push, no delivery. + // The checks still run on the causes every run collects, and the run is + // timed, so this is not free. Then a subscriber: the record is built, + // kept and delivered — the cost the lean gate spares. Both are measured + // for the failure message; the functional gate is pinned in + // attribution-lean-gate.test.ts, and the margin between the two is + // within this harness's noise. + const lean = measure(false); + const listened = measure(true); + + // 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. - // The cap trips when the enabled engine costs ~40% more per re-run - // than it does today; 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. The lean-posture work in - // documentation/plans/responsiveness-findings-plan.md is what would - // bring the ratio DOWN; ratchet the cap when it lands. + // 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. + for (const fold of FOLDS) await import(fold); const CAP = 14; let best = Infinity; let detail = ""; for (let round = 0; round < 3 && best >= CAP; round++) { - let idleMs = Infinity; - let enabledMs = Infinity; - for (let i = 0; i < 5; i++) { - idleMs = Math.min(idleMs, workload(tier, N, K)); - const release = attribution.enable({ log: false }); - try { - enabledMs = Math.min(enabledMs, workload(tier, N, K)); - } finally { - release(); - } - } - const ratio = enabledMs / idleMs; - if (ratio < best) { - best = ratio; - detail = `enabled ${enabledMs.toFixed(1)}ms / idle ${idleMs.toFixed(1)}ms`; + const folded = measure(true); + if (folded.ratio < best) { + best = folded.ratio; + detail = `folded ${folded.detail}; listened ${listened.detail}; lean ${lean.detail}`; } } expect(best, detail).toBeLessThan(CAP); diff --git a/packages/signals/tests/attribution-feedback.test.ts b/packages/signals/tests/attribution-feedback.test.ts index e5ee1069b..943526d59 100644 --- a/packages/signals/tests/attribution-feedback.test.ts +++ b/packages/signals/tests/attribution-feedback.test.ts @@ -2,7 +2,7 @@ * feedback(): what the user waited on, as ranked tables. * * Claim under test: feedback() is a pure fold over the records the engine - * already keeps — holds() and the interaction on each re-run — with no + * already keeps — `history("hold")` and the interaction on each re-run — with no * measurement of its own. `sources` ranks async sources by the silent time * writes spent held behind them and shows which affordances answered and how * often, so a source acknowledged on one screen and silent on another reads as @@ -386,7 +386,7 @@ describe("feedback()", () => { const [row] = feedback().sources; expect(row).toMatchObject({ holds: 1, silent: 0, long: 1 }); // One write: the tail is the whole hold. - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(row.longMs).toBe(hold.tailMs); expect(hold.tailMs).toBeLessThanOrEqual(hold.holdMs); }); diff --git a/packages/signals/tests/attribution-graph-growth.test.ts b/packages/signals/tests/attribution-graph-growth.test.ts index 5f573796f..e33585df0 100644 --- a/packages/signals/tests/attribution-graph-growth.test.ts +++ b/packages/signals/tests/attribution-graph-growth.test.ts @@ -20,9 +20,17 @@ import { OBSERVE, runWithOwner } from "../src/index.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -39,9 +47,9 @@ function arm(graphGrowth: { visits: number; ratio: number } | false = { visits: graphGrowth }); const graphs: GraphEvent[] = []; - attribution.subscribe("graph", e => graphs.push(e)); + on("graph", e => graphs.push(e)); const navigations: NavigationEvent[] = []; - attribution.subscribe("navigation", e => navigations.push(e)); + on("navigation", e => navigations.push(e)); const findings: DiagnosticEvent[] = []; OBSERVE!.diagnostics.subscribe(e => { if (e.code === "GRAPH_GROWTH") findings.push(e); diff --git a/packages/signals/tests/attribution-holds.test.ts b/packages/signals/tests/attribution-holds.test.ts index b87b23e31..9ed887251 100644 --- a/packages/signals/tests/attribution-holds.test.ts +++ b/packages/signals/tests/attribution-holds.test.ts @@ -140,7 +140,7 @@ describe("SILENT_HOLD", () => { expect(e.message).toContain("latest(page)"); expect(warn).toHaveBeenCalledTimes(1); - const holds = attribution.holds(); + const holds = attribution.history("hold"); expect(holds).toHaveLength(1); expect(holds[0]).toMatchObject({ heldWrites: [{ name: "page", prev: "1", value: "2" }], @@ -162,7 +162,7 @@ describe("SILENT_HOLD", () => { feed.resolve("a"); await until(() => feed.shown.includes("a-p1"), "initial load"); expect(events).toHaveLength(0); - expect(attribution.holds()).toHaveLength(0); + expect(attribution.history("hold")).toHaveLength(0); }); it("is cleared by an isPending() reader on the blocker", async () => { @@ -191,7 +191,7 @@ describe("SILENT_HOLD", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(0); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(acks(hold)).toContain("isPending:posts"); expect(hold.paintedDuringHold).toBeGreaterThan(0); // the spinner effect ran while parked // The structured face names WHERE it was painted: the reader's owner path. @@ -234,7 +234,7 @@ describe("SILENT_HOLD", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(0); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(hold.acknowledgements).toContainEqual({ kind: "optimistic", source: "saving", @@ -265,7 +265,7 @@ describe("SILENT_HOLD", () => { await until(() => feed.shown.includes("B-P2"), "the held page to land"); expect(events).toHaveLength(0); - expect(acks(attribution.holds()[0])).toContain("isPending:upper"); + expect(acks(attribution.history("hold")[0])).toContain("isPending:upper"); }); it("is cleared by a latest() reader on the held write", async () => { @@ -294,7 +294,7 @@ describe("SILENT_HOLD", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(0); - expect(acks(attribution.holds()[0])).toContain("latest:page"); + expect(acks(attribution.history("hold")[0])).toContain("latest:page"); }); it("is cleared by an optimistic value written alongside", async () => { @@ -321,7 +321,7 @@ describe("SILENT_HOLD", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(0); - expect(acks(attribution.holds()[0])).toContain("optimistic:saving"); + expect(acks(attribution.history("hold")[0])).toContain("optimistic:saving"); }); it("tiers by duration: below infoMs nothing, between info and warn an advisory", async () => { @@ -337,7 +337,7 @@ describe("SILENT_HOLD", () => { flush(); feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "fast page"); - expect(attribution.holds()).toHaveLength(1); + expect(attribution.history("hold")).toHaveLength(1); expect(events).toHaveLength(0); // Slow round-trip: advisory only — structured event, no console. @@ -346,7 +346,7 @@ describe("SILENT_HOLD", () => { await wait(40); feed.resolve("c"); await until(() => feed.shown.includes("c-p3"), "slow page"); - expect(attribution.holds()).toHaveLength(2); + expect(attribution.history("hold")).toHaveLength(2); expect(events).toHaveLength(1); expect(events[0].severity).toBe("info"); expect(warn).not.toHaveBeenCalled(); @@ -365,7 +365,7 @@ describe("SILENT_HOLD", () => { feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(0); - expect(attribution.holds()).toHaveLength(0); + expect(attribution.history("hold")).toHaveLength(0); }); it("reports an action's plain writes with the optimistic repair", async () => { @@ -395,7 +395,7 @@ describe("SILENT_HOLD", () => { expect(events[0].data).toMatchObject({ heldWrites: ["title"], action: true }); expect(events[0].message).toContain("an action held"); expect(events[0].message).toContain("createOptimistic"); - expect(attribution.holds()[0]).toMatchObject({ + expect(attribution.history("hold")[0]).toMatchObject({ action: true, heldWrites: [{ name: "title", prev: '"draft"', value: '"saved"' }] }); @@ -424,7 +424,7 @@ describe("SILENT_HOLD", () => { await until(() => name() === "saved", "the action to commit"); expect(events).toHaveLength(0); - expect(acks(attribution.holds()[0])).toContain("optimistic:pendingTitle"); + expect(acks(attribution.history("hold")[0])).toContain("optimistic:pendingTitle"); }); }); @@ -456,7 +456,7 @@ describe("what can paint while held", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(1); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(hold.paintedDuringHold).toBe(0); expect(hold.heldWrites.map(w => w.name).sort()).toEqual(["page", "saving"]); expect(hold.interaction).toMatchObject({ kind: "interaction", name: "click" }); @@ -488,7 +488,7 @@ describe("what can paint while held", () => { await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(events).toHaveLength(1); - expect(attribution.holds()[0].paintedDuringHold).toBe(0); + expect(attribution.history("hold")[0].paintedDuringHold).toBe(0); }); }); @@ -595,7 +595,7 @@ describe("LONG_HOLD", () => { feed.resolve("c"); await until(() => feed.shown.includes("c-p3"), "the final page to land"); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); // holdMs reaches back to the first parked flush even though "page" now // carries only the second click's record. expect(hold.holdMs).toBeGreaterThanOrEqual(pause + tail); @@ -664,6 +664,6 @@ describe("LONG_HOLD", () => { feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "the held page to land"); expect(longEvents).toHaveLength(0); - expect(attribution.holds()[0].tailMs).toBeGreaterThanOrEqual(waited); + expect(attribution.history("hold")[0].tailMs).toBeGreaterThanOrEqual(waited); }); }); diff --git a/packages/signals/tests/attribution-interactions.test.ts b/packages/signals/tests/attribution-interactions.test.ts index 94643d77d..13385c2ac 100644 --- a/packages/signals/tests/attribution-interactions.test.ts +++ b/packages/signals/tests/attribution-interactions.test.ts @@ -6,9 +6,9 @@ * — the handler's return when it wrote nothing (`idle`), the drain that * committed its writes (`committed`), or the commit of the last hold they * waited in (`held`) — with the re-runs, creations, holds and navigations it - * caused attached. `subscribe(type, …)` delivers each record kind at the - * moment it is complete, so a consumer never polls a ring buffer to learn - * that something finished. + * caused attached. `OBSERVE.records.subscribe(type, …)` delivers each record + * kind at the moment it is complete, so a consumer never polls a ring buffer + * to learn that something finished. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { attribution, feedback } from "../src/attribution.js"; @@ -28,9 +28,17 @@ import { flush, OBSERVE } from "../src/index.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -58,16 +66,16 @@ function arm(opts: { holds?: false } = {}) { holds: opts.holds ?? { infoMs: 0, warnMs: 0 } }); const delivered: { type: string; record: object }[] = []; - attribution.subscribe("rerun", r => delivered.push({ type: "rerun", record: r })); - attribution.subscribe("interaction", r => delivered.push({ type: "interaction", record: r })); - attribution.subscribe("hold", r => delivered.push({ type: "hold", record: r })); - attribution.subscribe("navigation", r => delivered.push({ type: "navigation", record: r })); + on("rerun", r => delivered.push({ type: "rerun", record: r })); + on("interaction", r => delivered.push({ type: "interaction", record: r })); + on("hold", r => delivered.push({ type: "hold", record: r })); + on("navigation", r => delivered.push({ type: "navigation", record: r })); const of = (type: string) => delivered.filter(d => d.type === type).map(d => d.record as T); return { delivered, - interactions: () => of("interaction"), + interactionLog: () => of("interaction"), holds: () => of("hold"), - navigations: () => of("navigation"), + navigationLog: () => of("navigation"), reruns: () => of("rerun") }; } @@ -100,10 +108,10 @@ function pagedFeed() { describe("InteractionEvent", () => { it("settles idle when the handler wrote nothing, as the frame closes", () => { - const { interactions } = arm(); + const { interactionLog } = arm(); const before = performance.now(); OBSERVE!.attribution.withInteraction(CLICK, () => {}); - const [e] = interactions(); + const [e] = interactionLog(); expect(e).toBeDefined(); expect(e).toMatchObject({ name: "click", @@ -118,14 +126,14 @@ describe("InteractionEvent", () => { expect(e.at).toBeGreaterThanOrEqual(before); expect(e.inputDelayMs).toBeUndefined(); expect(e.settledMs).toBe(e.handlerMs); - expect(attribution.interactions()).toEqual([e]); + expect(attribution.history("interaction")).toEqual([e]); }); it("dates itself from the runtime's `at` and reports the gap to handler entry as input delay", () => { - const { interactions } = arm(); + const { interactionLog } = arm(); const at = performance.now() - 20; OBSERVE!.attribution.withInteraction({ ...CLICK, at }, () => {}); - const [e] = interactions(); + const [e] = interactionLog(); expect(e.at).toBe(at); expect(e.inputDelayMs).toBeGreaterThanOrEqual(20); // The handler ran for next to nothing; what the person waited was the queue. @@ -135,7 +143,7 @@ describe("InteractionEvent", () => { }); it("stays open until the drain that committed its writes, counting the re-runs it caused", () => { - const { interactions, reruns } = arm(); + const { interactionLog, reruns } = arm(); const [count, setCount] = createSignal(0, { name: "count" }); const doubled = createMemo(() => count() * 2, { name: "doubled" }); createRoot(() => createEffect(doubled, () => {}, { name: "reader" })); @@ -143,14 +151,14 @@ describe("InteractionEvent", () => { OBSERVE!.attribution.withInteraction(CLICK, () => setCount(1)); // The handler returned; the flush that shows the write has not run. - expect(interactions()).toHaveLength(0); - const open = attribution.interactions()[0]; + expect(interactionLog()).toHaveLength(0); + const open = attribution.history("interaction")[0]; expect(open.outcome).toBeUndefined(); expect(open.writes).toBe(1); expect(open.handlerMs).toBeGreaterThanOrEqual(0); flush(); - const [e] = interactions(); + const [e] = interactionLog(); expect(e).toBe(open); expect(e.outcome).toBe("committed"); expect(e.runs).toBe(2); // doubled + reader @@ -167,7 +175,7 @@ describe("InteractionEvent", () => { }); it("charges computations created in its runs to the interaction", () => { - const { interactions } = arm(); + const { interactionLog } = arm(); const [items, setItems] = createSignal([], { name: "items" }); // A parent whose recompute builds a child per item — the create-run shape // mapArray produces, which no RerunEvent ever describes. @@ -185,14 +193,14 @@ describe("InteractionEvent", () => { OBSERVE!.attribution.withInteraction(CLICK, () => setItems([1, 2, 3])); flush(); - const [e] = interactions(); + const [e] = interactionLog(); expect(e.outcome).toBe("committed"); expect(e.created).toBe(3); expect(e.runs).toBe(2); // rows + list }); it("waits for the hold its writes landed in, and attaches it", async () => { - const { interactions, holds, delivered } = arm(); + const { interactionLog, holds, delivered } = arm(); const feed = pagedFeed(); flush(); feed.resolve("a"); @@ -201,7 +209,7 @@ describe("InteractionEvent", () => { OBSERVE!.attribution.withInteraction(CLICK, () => feed.setPage(2)); flush(); expect(feed.shown).toEqual(["a-p1"]); // held - expect(interactions()).toHaveLength(0); + expect(interactionLog()).toHaveLength(0); // Bracket the wait on the engine's own clock: a 10ms timer can fire a // hair under 10ms of `performance.now()`. const armed = performance.now(); @@ -210,7 +218,7 @@ describe("InteractionEvent", () => { feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "the held page to land"); - const [e] = interactions(); + const [e] = interactionLog(); expect(e.outcome).toBe("held"); expect(e.holds).toHaveLength(1); const [hold] = holds(); @@ -226,7 +234,7 @@ describe("InteractionEvent", () => { }); it("still settles held when hold tracking is off", async () => { - const { interactions } = arm({ holds: false }); + const { interactionLog } = arm({ holds: false }); const feed = pagedFeed(); flush(); feed.resolve("a"); @@ -234,16 +242,16 @@ describe("InteractionEvent", () => { OBSERVE!.attribution.withInteraction(CLICK, () => feed.setPage(2)); flush(); - expect(interactions()).toHaveLength(0); + expect(interactionLog()).toHaveLength(0); feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "the held page to land"); - const [e] = interactions(); + const [e] = interactionLog(); expect(e.outcome).toBe("held"); expect(e.holds).toEqual([]); }); it("attaches the navigation it performed and settles with it", async () => { - const { interactions, navigations, delivered } = arm(); + const { interactionLog, navigationLog, delivered } = arm(); const feed = pagedFeed(); flush(); feed.resolve("a"); @@ -256,15 +264,15 @@ describe("InteractionEvent", () => { ) ); flush(); - expect(interactions()).toHaveLength(0); - expect(attribution.interactions()[0].navigations).toHaveLength(1); + expect(interactionLog()).toHaveLength(0); + expect(attribution.history("interaction")[0].navigations).toHaveLength(1); feed.resolve("b"); await until(() => feed.shown.includes("b-p2"), "the held page to land"); - const [e] = interactions(); - const [nav] = navigations(); + const [e] = interactionLog(); + const [nav] = navigationLog(); expect(e.navigations[0]).toBe(nav); - expect(nav).toBe(attribution.navigations()[0]); + expect(nav).toBe(attribution.history("navigation")[0]); expect(nav.outcome).toBe("held"); expect(nav.interaction).toBe(e.origin); expect(e.outcome).toBe("held"); @@ -273,7 +281,7 @@ describe("InteractionEvent", () => { }); it("keeps one record per dispatch where feedback() folds by name", () => { - const { interactions } = arm(); + const { interactionLog } = arm(); const [count, setCount] = createSignal(0, { name: "count" }); createRoot(() => createEffect(count, () => {}, { name: "reader" })); flush(); @@ -281,8 +289,8 @@ describe("InteractionEvent", () => { flush(); OBSERVE!.attribution.withInteraction(CLICK, () => setCount(2)); flush(); - expect(interactions()).toHaveLength(2); - expect(attribution.interactions()).toHaveLength(2); + expect(interactionLog()).toHaveLength(2); + expect(attribution.history("interaction")).toHaveLength(2); expect(feedback().interactions).toHaveLength(1); expect(feedback().interactions[0].dispatches).toBe(2); }); @@ -296,22 +304,17 @@ describe("a handler that returns a promise", () => { vi.spyOn(console, "info").mockImplementation(() => {}); attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false, holds }); const records: InteractionEvent[] = []; - attribution.subscribe("interaction", r => records.push(r)); + on("interaction", r => records.push(r)); const findings: DiagnosticEvent[] = []; const off = OBSERVE!.diagnostics.subscribe(e => { if (e.code === "UNTRACKED_ASYNC_HANDLER") findings.push(e); }); offs.push(off); - return { interactions: () => records, findings }; + return { interactionLog: () => records, findings }; } - const offs: (() => void)[] = []; - afterEach(() => { - for (const off of offs) off(); - offs.length = 0; - }); it("keeps the record open until the promise settles and reports the continuation", async () => { - const { interactions } = armWithFindings(); + const { interactionLog } = armWithFindings(); let resolve!: () => void; const pending = new Promise(r => (resolve = r)); OBSERVE!.attribution.withInteraction(CLICK, async () => { @@ -319,12 +322,12 @@ describe("a handler that returns a promise", () => { }); flush(); // The frame closed, but the person is still waiting. - expect(interactions()).toHaveLength(0); - expect(attribution.interactions()[0]?.settledMs).toBeUndefined(); + expect(interactionLog()).toHaveLength(0); + expect(attribution.history("interaction")[0]?.settledMs).toBeUndefined(); await wait(30); resolve(); - await until(() => interactions().length === 1, "the handler's promise to settle the record"); - const [e] = interactions(); + await until(() => interactionLog().length === 1, "the handler's promise to settle the record"); + const [e] = interactionLog(); expect(e.continuationMs).toBeGreaterThanOrEqual(25); expect(e.settledMs).toBeGreaterThanOrEqual( (e.inputDelayMs ?? 0) + e.handlerMs + e.continuationMs! @@ -333,25 +336,25 @@ describe("a handler that returns a promise", () => { }); it("a rejected handler promise ends the wait too", async () => { - const { interactions } = armWithFindings(); + const { interactionLog } = armWithFindings(); const failing = OBSERVE!.attribution.withInteraction(CLICK, async () => { await wait(5); throw new Error("save failed"); }); await failing.catch(() => {}); - await until(() => interactions().length === 1, "the rejected promise to settle the record"); - expect(interactions()[0].continuationMs).toBeGreaterThanOrEqual(4); + await until(() => interactionLog().length === 1, "the rejected promise to settle the record"); + expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(4); }); it("finds a handler that awaited with nothing on screen able to show it", async () => { - const { interactions, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); + const { interactionLog, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); const [, setResult] = createSignal("", { name: "result" }); // `onClick={async () => setResult(await save())}`: no write before the await. OBSERVE!.attribution.withInteraction(CLICK, async () => { await wait(30); setResult("saved"); }); - await until(() => interactions().length === 1, "the record to settle"); + await until(() => interactionLog().length === 1, "the record to settle"); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ code: "UNTRACKED_ASYNC_HANDLER", @@ -376,26 +379,26 @@ describe("a handler that returns a promise", () => { // by choice. Real timers still drive the await; only the stamps are ours. let t = 1000; vi.spyOn(performance, "now").mockImplementation(() => t); - const { interactions, findings } = armWithFindings({ infoMs: 10, warnMs: 1000 }); + const { interactionLog, findings } = armWithFindings({ infoMs: 10, warnMs: 1000 }); OBSERVE!.attribution.withInteraction(CLICK, async () => { await wait(1); t += 9; // one short of infoMs }); - await until(() => interactions().length === 1, "the fast handler to settle"); - expect(interactions()[0].continuationMs).toBe(9); + await until(() => interactionLog().length === 1, "the fast handler to settle"); + expect(interactionLog()[0].continuationMs).toBe(9); expect(findings).toHaveLength(0); OBSERVE!.attribution.withInteraction(CLICK, async () => { await wait(1); t += 30; // past infoMs, short of warnMs }); - await until(() => interactions().length === 2, "the slow handler to settle"); - expect(interactions()[1].continuationMs).toBe(30); + await until(() => interactionLog().length === 2, "the slow handler to settle"); + expect(interactionLog()[1].continuationMs).toBe(30); expect(findings).toHaveLength(1); expect(findings[0].severity).toBe("info"); }); it("a write before the await is the acknowledgement: no finding", async () => { - const { interactions, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); + const { interactionLog, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); const [saving, setSaving] = createSignal(false, { name: "saving" }); createRoot(() => createRenderEffect(saving, () => {}, { name: "spinner" })); flush(); @@ -404,58 +407,64 @@ describe("a handler that returns a promise", () => { await wait(30); setSaving(false); }); - await until(() => interactions().length === 1, "the record to settle"); + await until(() => interactionLog().length === 1, "the record to settle"); expect(findings).toHaveLength(0); - expect(interactions()[0]).toMatchObject({ writes: 1, outcome: "committed" }); - expect(interactions()[0].continuationMs).toBeGreaterThanOrEqual(25); + expect(interactionLog()[0]).toMatchObject({ writes: 1, outcome: "committed" }); + expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(25); }); it("an action started in the handler is tracked work: no finding", async () => { - const { interactions, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); + const { interactionLog, findings } = armWithFindings({ infoMs: 10, warnMs: 20 }); const [, setResult] = createSignal("", { name: "result" }); const save = action(function* save() { yield wait(30); setResult("saved"); }); OBSERVE!.attribution.withInteraction(CLICK, () => save()); - await until(() => interactions().length === 1, "the action-backed handler to settle"); + await until(() => interactionLog().length === 1, "the action-backed handler to settle"); expect(findings).toHaveLength(0); }); it("with hold tracking off, the record still waits but nothing is judged", async () => { - const { interactions, findings } = armWithFindings(false); + const { interactionLog, findings } = armWithFindings(false); OBSERVE!.attribution.withInteraction(CLICK, async () => { await wait(20); }); - await until(() => interactions().length === 1, "the record to settle"); - expect(interactions()[0].continuationMs).toBeGreaterThanOrEqual(15); + await until(() => interactionLog().length === 1, "the record to settle"); + expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(15); expect(findings).toHaveLength(0); }); }); -describe("subscribe(type, listener)", () => { - it("the bare form is the rerun channel; unsubscribe and disable() drop listeners", () => { +describe("OBSERVE.records.subscribe(type, listener)", () => { + it("unsubscribe stops delivery; disable() uninstalls the engine but keeps the subscription", () => { arm(); - const bare: RerunEvent[] = []; - const typed: RerunEvent[] = []; - const off = attribution.subscribe(r => bare.push(r)); - attribution.subscribe("rerun", r => typed.push(r)); + const first: RerunEvent[] = []; + const second: RerunEvent[] = []; + const off = OBSERVE!.records.subscribe("rerun", r => first.push(r)); + on("rerun", r => second.push(r)); const [count, setCount] = createSignal(0, { name: "count" }); createRoot(() => createEffect(count, () => {}, { name: "reader" })); flush(); setCount(1); flush(); - expect(bare).toHaveLength(1); - expect(typed).toEqual(bare); + expect(first).toHaveLength(1); + expect(second).toEqual(first); off(); setCount(2); flush(); - expect(bare).toHaveLength(1); - expect(typed).toHaveLength(2); + expect(first).toHaveLength(1); + expect(second).toHaveLength(2); + // The subscription is the channel's, not the engine's: with the engine + // uninstalled nothing is emitted, and the listener hears the next + // engine's records without resubscribing. attribution.disable(); - attribution.enable({ log: false }); setCount(3); flush(); - expect(typed).toHaveLength(2); + expect(second).toHaveLength(2); + attribution.enable({ log: false }); + setCount(4); + flush(); + expect(second).toHaveLength(3); }); }); diff --git a/packages/signals/tests/attribution-lean-gate.test.ts b/packages/signals/tests/attribution-lean-gate.test.ts new file mode 100644 index 000000000..8d5daab27 --- /dev/null +++ b/packages/signals/tests/attribution-lean-gate.test.ts @@ -0,0 +1,157 @@ +/** + * The lean gate: an enabled engine builds a `RerunEvent` only when something + * wants it — a `rerun` listener on the records channel, a registered fold, or + * the console log. Otherwise every run still leaves its facts on the node and + * feeds the checks, but no record is allocated, kept or delivered. + * + * This file imports the engine's core module, not the `attribution` entry: + * the entry re-exports `costs`/`feedback`, whose modules register a fold on + * evaluation and would turn the gate on for the whole file. The fold leg of + * the gate is therefore exercised last — a fold cannot be unregistered. + */ +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"; +import type { RecordListener } from "../src/core/dev.js"; + +// Channel subscriptions are the consumer's, not the engine's: released here, +// or one test's listener would turn the gate on for the next. +const offs: (() => void)[] = []; +function onRerun(listener: RecordListener<"rerun">): () => void { + const off = OBSERVE!.records.subscribe("rerun", listener); + offs.push(off); + return off; +} + +afterEach(() => { + for (const off of offs.splice(0)) off(); + attribution.disable(); + flush(); + vi.restoreAllMocks(); +}); + +/** One signal → effect chain; returns the setter and the effect node. */ +function chain() { + const [n, setN] = createSignal(0, { name: "n" }); + let node!: object; + createRoot(() => { + createEffect( + () => { + node = getOwner()!; + return n(); + }, + () => {}, + { name: "e" } + ); + }); + flush(); + return { setN, node }; +} + +describe("attribution engine: lean gate", () => { + it("enabled with nobody wanting records, a run builds no RerunEvent", () => { + const { setN, node } = chain(); + attribution.enable({ log: false }); + setN(1); + flush(); + setN(2); + flush(); + // Nothing kept, nothing identified: the record path was never entered. + expect(attribution.history("rerun")).toEqual([]); + expect(nodeIdOf(node)).toBeUndefined(); + expect(OBSERVE!.records.observed("rerun")).toBe(false); + }); + + it("a rerun listener turns the record on — and off again when it leaves", () => { + const { setN, node } = chain(); + attribution.enable({ log: false }); + setN(1); + flush(); + expect(attribution.history("rerun")).toEqual([]); + + const seen: RerunEvent[] = []; + const lives: unknown[] = []; + const off = onRerun((e, live) => { + seen.push(e); + lives.push(live); + }); + setN(2); + flush(); + expect(seen).toHaveLength(1); + // The run count was kept while nobody listened: this is the node's second + // run under the engine, the first having left no record. + expect(seen[0]).toMatchObject({ nodeName: "e", nodeRuns: 2 }); + expect(lives).toEqual([node]); + expect(attribution.history("rerun")).toEqual(seen); + expect(nodeIdOf(node)).toBe(seen[0].nodeId); + + off(); + setN(3); + flush(); + expect(seen).toHaveLength(1); + expect(attribution.history("rerun")).toEqual(seen); + }); + + it("the gate is read at run start: a listener arriving mid-run gets the next record", () => { + const [n, setN] = createSignal(0, { name: "n" }); + const seen: RerunEvent[] = []; + let armed = false; + createRoot(() => { + createEffect( + () => { + const v = n(); + if (armed && v === 1) onRerun(e => seen.push(e)); + return v; + }, + () => {}, + { name: "e" } + ); + }); + flush(); + attribution.enable({ log: false }); + armed = true; + setN(1); + flush(); + // Subscribed inside the run that nobody wanted at its start: no record. + expect(seen).toEqual([]); + setN(2); + flush(); + expect(seen.map(e => e.nodeName)).toEqual(["e"]); + }); + + it("the console log wants the record", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const { setN } = chain(); + attribution.enable({ log: true }); + setN(1); + flush(); + expect(attribution.history("rerun")).toHaveLength(1); + expect(log).toHaveBeenCalled(); + }); + + it("the checks run without a record: a hot scope still warns", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { setN } = chain(); + attribution.enable({ log: false, hotRuns: { count: 3, windowMs: 10_000 } }); + for (let i = 1; i <= 4; i++) { + setN(i); + flush(); + } + expect(attribution.history("rerun")).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain("HOT_SCOPE"); + }); + + // Last: registering a fold is for the rest of the process. + it("a registered fold wants the record", () => { + const folded: RerunEvent[] = []; + registerFold({ rerun: (_el, e) => folded.push(e) }); + const { setN } = chain(); + attribution.enable({ log: false }); + setN(1); + flush(); + expect(folded).toHaveLength(1); + expect(attribution.history("rerun")).toEqual(folded); + }); +}); diff --git a/packages/signals/tests/attribution-navigation.test.ts b/packages/signals/tests/attribution-navigation.test.ts index a747a0c20..072dc7952 100644 --- a/packages/signals/tests/attribution-navigation.test.ts +++ b/packages/signals/tests/attribution-navigation.test.ts @@ -27,9 +27,17 @@ import { } from "../src/index.js"; import type { NavigationRef } from "../src/index.js"; import type { RerunEvent } from "../src/core/attribution.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -57,7 +65,7 @@ function arm(opts: { holds?: false } = {}) { holds: opts.holds ?? { infoMs: 0, warnMs: 0 } }); const runs: RerunEvent[] = []; - attribution.subscribe(e => runs.push(e)); + on("rerun", e => runs.push(e)); const silent: DiagnosticEvent[] = []; OBSERVE!.diagnostics.subscribe(e => { if (e.code === "SILENT_HOLD") silent.push(e); @@ -166,7 +174,7 @@ describe("withOrigin — navigation provenance", () => { name: "/todos/:id", interaction: { kind: "interaction", name: "click" } }); - expect(attribution.navigations().at(-1)!.interaction).toMatchObject({ name: "click" }); + expect(attribution.history("navigation").at(-1)!.interaction).toMatchObject({ name: "click" }); }); it("is a plain call when no engine is installed", () => { @@ -180,11 +188,11 @@ describe("withOrigin — navigation provenance", () => { expect(result).toBe(42); flush(); expect(location()).toBe("/b"); - expect(attribution.navigations()).toEqual([]); + expect(attribution.history("navigation")).toEqual([]); }); }); -describe("navigations() — one settled record per frame", () => { +describe('history("navigation") — one settled record per frame', () => { it("settles a navigation no transition held as committed at the end of the drain", () => { arm(); const [location, setLocation] = createSignal("/users", { name: "location" }); @@ -193,7 +201,7 @@ describe("navigations() — one settled record per frame", () => { OBSERVE!.attribution.withInteraction(CLICK, () => OBSERVE!.attribution.withOrigin(NAV, () => setLocation("/users/42")) ); - const [open] = attribution.navigations(); + const [open] = attribution.history("navigation"); expect(open).toMatchObject({ name: "/users/:id", to: "/users/42", @@ -208,7 +216,7 @@ describe("navigations() — one settled record per frame", () => { expect(open.outcome).toBe("committed"); expect(open.settledMs).toBeGreaterThanOrEqual(0); expect(open.hold).toBeUndefined(); - expect(attribution.navigations()).toHaveLength(1); + expect(attribution.history("navigation")).toHaveLength(1); }); it("settles immediately when no write survived the equality gate", () => { @@ -217,7 +225,7 @@ describe("navigations() — one settled record per frame", () => { OBSERVE!.attribution.withOrigin({ kind: "navigation", name: "/users", to: "/users" }, () => setLocation("/users") ); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.writes).toBe(0); expect(nav.outcome).toBe("committed"); }); @@ -230,7 +238,7 @@ describe("navigations() — one settled record per frame", () => { OBSERVE!.attribution.withOrigin({ kind: "navigation", name: "/b", to: "/b" }, () => flush(() => setLocation("/b")) ); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.writes).toBe(1); expect(nav.outcome).toBe("committed"); }); @@ -257,7 +265,7 @@ describe("navigations() — one settled record per frame", () => { setAuthed(false); flush(); expect(location()).toBe("/login"); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav).toMatchObject({ name: "/login", writes: 1, outcome: "committed" }); expect(nav.origin.interaction).toBeUndefined(); }); @@ -274,7 +282,7 @@ describe("navigations() — one settled record per frame", () => { ); flush(); expect(app.shown).toEqual(["alice@/users"]); // held: nothing painted - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.outcome).toBeUndefined(); // still waiting on the page // Bracket the wait on the engine's own clock: a 10ms timer can fire a // hair under 10ms of `performance.now()`. @@ -287,7 +295,7 @@ describe("navigations() — one settled record per frame", () => { expect(nav.outcome).toBe("held"); expect(nav.hold).toBeDefined(); expect(nav.settledMs).toBeGreaterThanOrEqual(waited); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(nav.hold).toBe(hold); // The hold names the navigation, and joins to it by identity. expect(hold.origin).toBe(nav.origin); @@ -321,7 +329,7 @@ describe("navigations() — one settled record per frame", () => { expect(silent[0].message).toContain( `[SILENT_HOLD] navigation to /users/:id (/users/42) wrote "location"` ); - expect(attribution.holds()[0].interaction).toBeUndefined(); + expect(attribution.history("hold")[0].interaction).toBeUndefined(); }); it("still settles a held navigation when hold tracking is off", async () => { @@ -333,7 +341,7 @@ describe("navigations() — one settled record per frame", () => { OBSERVE!.attribution.withOrigin(NAV, () => app.setLocation("/users/42")); flush(); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.outcome).toBeUndefined(); await wait(10); app.resolve("b"); @@ -341,7 +349,7 @@ describe("navigations() — one settled record per frame", () => { expect(nav.outcome).toBe("held"); expect(nav.hold).toBeUndefined(); // nothing recorded it - expect(attribution.holds()).toEqual([]); + expect(attribution.history("hold")).toEqual([]); }); it("marks a navigation superseded when a later one replaces its write before it lands", async () => { @@ -358,7 +366,7 @@ describe("navigations() — one settled record per frame", () => { () => app.setLocation("/users/43") ); flush(); - const [first, second] = attribution.navigations(); + const [first, second] = attribution.history("navigation"); expect(first.outcome).toBe("superseded"); expect(first.hold).toBeUndefined(); expect(second.outcome).toBeUndefined(); @@ -367,7 +375,7 @@ describe("navigations() — one settled record per frame", () => { await until(() => app.shown.includes("b@/users/43"), "the second page to land"); expect(second.outcome).toBe("held"); expect(second.hold!.origin).toBe(second.origin); - expect(attribution.navigations()).toHaveLength(2); + expect(attribution.history("navigation")).toHaveLength(2); }); it("folds settled navigations into feedback().navigations by route", async () => { @@ -431,7 +439,7 @@ describe("navigations() — one settled record per frame", () => { OBSERVE!.attribution.withOrigin(ref, () => app.setLocation("/admin/users/42")) ); flush(); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.name).toBe("/admin/*"); expect(nav.params).toBeUndefined(); // The subtree resolves inside the hold; the router fills in the exact match. @@ -462,11 +470,11 @@ describe("navigations() — one settled record per frame", () => { setLocation("/b") ); flush(); - expect(attribution.navigations()).toHaveLength(1); + expect(attribution.history("navigation")).toHaveLength(1); attribution.disable(); - expect(attribution.navigations()).toEqual([]); + expect(attribution.history("navigation")).toEqual([]); arm(); - expect(attribution.navigations()).toEqual([]); + expect(attribution.history("navigation")).toEqual([]); expect(feedback().navigations).toEqual([]); }); }); @@ -495,15 +503,15 @@ describe("redirects — one navigation, several destinations", () => { const hopAt = performance.now(); OBSERVE!.attribution.withOrigin(LOGIN, () => app.setLocation("/login")); flush(); - expect(attribution.navigations()).toHaveLength(1); - const [nav] = attribution.navigations(); + expect(attribution.history("navigation")).toHaveLength(1); + const [nav] = attribution.history("navigation"); expect(nav.outcome).toBeUndefined(); await wait(10); const waited = performance.now() - armed; app.resolve("b"); await until(() => app.shown.includes("b@/login"), "the redirect target to land"); - expect(attribution.navigations()).toHaveLength(1); + expect(attribution.history("navigation")).toHaveLength(1); expect(nav).toMatchObject({ name: "/login", to: "/login", @@ -522,7 +530,7 @@ describe("redirects — one navigation, several destinations", () => { expect(nav.at).toBeLessThan(hopAt); expect(nav.settledMs).toBeGreaterThanOrEqual(waited); // One hold, joined by identity, named by the whole chain. - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(nav.hold).toBe(hold); expect(hold.origin).toBe(nav.origin); expect(formatOrigin(nav.origin)).toBe("navigation to /login (redirected from /users/42)"); @@ -561,8 +569,8 @@ describe("redirects — one navigation, several destinations", () => { app.resolve("b"); await until(() => app.shown.includes("b@/sso?next=%2Flogin"), "the final target to land"); - const [nav] = attribution.navigations(); - expect(attribution.navigations()).toHaveLength(1); + const [nav] = attribution.history("navigation"); + expect(attribution.history("navigation")).toHaveLength(1); expect(nav).toMatchObject({ name: "/sso", writes: 3, outcome: "held" }); expect(nav.redirects!.map(h => h.to)).toEqual(["/users/42", "/login"]); expect(formatOrigin(nav.origin)).toBe( @@ -588,11 +596,11 @@ describe("redirects — one navigation, several destinations", () => { } ) ); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); // Neither close settled it early: the outer frame was still open. expect(nav.outcome).toBeUndefined(); flush(); - expect(attribution.navigations()).toHaveLength(1); + expect(attribution.history("navigation")).toHaveLength(1); expect(nav).toMatchObject({ name: "/dashboard", from: "/start", @@ -612,7 +620,7 @@ describe("redirects — one navigation, several destinations", () => { flush(); OBSERVE!.attribution.withOrigin(LOGIN, () => setLocation("/login")); flush(); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav).toMatchObject({ name: "/login", writes: 1, outcome: "committed" }); expect(nav.redirects).toBeUndefined(); expect(feedback().navigations[0].redirected).toBe(0); @@ -652,7 +660,7 @@ describe("hold census — a router's own reads are not acknowledgement", () => { r.resolve("b"); await until(() => r.shown.includes("b@/users/42"), "the held page to land"); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(hold.acknowledgements).toEqual([]); expect(silent).toHaveLength(1); const [source] = feedback().sources; @@ -674,7 +682,7 @@ describe("hold census — a router's own reads are not acknowledgement", () => { r.resolve("b"); await until(() => r.shown.includes("b@/users/42"), "the held page to land"); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(hold.acknowledgements).toContainEqual( expect.objectContaining({ kind: "isPending", source: "location" }) ); @@ -699,7 +707,7 @@ describe("at — a router whose request predates the write it wraps", () => { const waited = performance.now() - requested; OBSERVE!.attribution.withOrigin({ ...NAV, at: requested }, () => app.setLocation("/users/42")); flush(); - const [nav] = attribution.navigations(); + const [nav] = attribution.history("navigation"); expect(nav.at).toBe(requested); expect(nav.origin.at).toBe(requested); app.resolve("b"); diff --git a/packages/signals/tests/attribution-optimistic-revert.test.ts b/packages/signals/tests/attribution-optimistic-revert.test.ts index 13a5318a4..75773edcf 100644 --- a/packages/signals/tests/attribution-optimistic-revert.test.ts +++ b/packages/signals/tests/attribution-optimistic-revert.test.ts @@ -230,6 +230,6 @@ describe("OPTIMISTIC_REVERTED", () => { await p; flush(); expect(status()).toBe("idle"); - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); }); }); diff --git a/packages/signals/tests/attribution-provenance.test.ts b/packages/signals/tests/attribution-provenance.test.ts index 9e0121b23..3c5ec6dcd 100644 --- a/packages/signals/tests/attribution-provenance.test.ts +++ b/packages/signals/tests/attribution-provenance.test.ts @@ -22,9 +22,17 @@ import { OBSERVE } from "../src/index.js"; import type { ChangeOrigin, RerunEvent } from "../src/core/attribution.js"; -import type { DiagnosticEvent } from "../src/core/dev.js"; +import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -46,7 +54,7 @@ function arm() { vi.spyOn(console, "info").mockImplementation(() => {}); attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false }); const runs: RerunEvent[] = []; - attribution.subscribe(e => runs.push(e)); + on("rerun", e => runs.push(e)); return runs; } @@ -263,7 +271,7 @@ describe("holds carry their interaction", () => { resolve("b"); await until(() => shown.includes("b-p2"), "the held page to land"); - const [hold] = attribution.holds(); + const [hold] = attribution.history("hold"); expect(hold.interaction).toMatchObject({ kind: "interaction", name: "click", at }); expect(hold.holdMs).toBeGreaterThanOrEqual(1000); expect(hold.heldWrites[0].origin).toBe(hold.interaction); diff --git a/packages/signals/tests/attribution-timeline.test.ts b/packages/signals/tests/attribution-timeline.test.ts index 9d87d9ed4..218632719 100644 --- a/packages/signals/tests/attribution-timeline.test.ts +++ b/packages/signals/tests/attribution-timeline.test.ts @@ -4,7 +4,8 @@ * Claim under test: each is one record per run/callback/drain/flight/show * carrying the engine's own timings and the interaction the work traced to, * built only while a listener for its type exists, and entering no ring - * buffer — `history()` stays re-runs only however many creations happen. + * buffer — `history("rerun")` stays re-runs only however many creations + * happen. The live node the record is about is delivered beside it. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -13,7 +14,8 @@ import { type EffectRunEvent, type FallbackEvent, type FlightEvent, - type FlushEvent + type FlushEvent, + type RerunEvent } from "../src/attribution.js"; import { createLoadingBoundary, @@ -24,8 +26,18 @@ import { flush, OBSERVE } from "../src/index.js"; +import type { RecordListener, RecordType } from "../src/core/dev.js"; +import type { Computed } from "../src/core/types.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -73,7 +85,11 @@ describe("create records", () => { it("delivers one record per creation run with the interaction that built the node", () => { arm(); const creates: CreateEvent[] = []; - attribution.subscribe("create", e => creates.push(e)); + const liveOf = new WeakMap>(); + on("create", (e, live) => { + creates.push(e); + liveOf.set(e, live); + }); const [count] = createSignal(1, { name: "count" }); click(() => { createRoot(() => { @@ -96,19 +112,19 @@ describe("create records", () => { expect(double.interaction).toMatchObject({ kind: "interaction", name: "click" }); expect(paint.interaction).toBe(double.interaction); expect(typeof double.nodeId).toBe("number"); - expect(OBSERVE!.subjectOf(double)).toBeDefined(); + expect(liveOf.get(double)).toBeDefined(); // Creations never enter the re-run history. - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); }); - it("is listener-gated and never enters history()", () => { + it('is listener-gated and never enters history("rerun")', () => { arm(); const { setCount } = counter(); - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); setCount(1); flush(); // The re-run is in history; no creation record was ever built. - expect(attribution.history().map(r => r.nodeName)).toEqual(["double", "paint"]); + expect(attribution.history("rerun").map(r => r.nodeName)).toEqual(["double", "paint"]); }); }); @@ -116,7 +132,12 @@ describe("effect records", () => { it("times each effect callback and joins a re-run's callback to its compute run", () => { arm(); const effects: EffectRunEvent[] = []; - attribution.subscribe("effect", e => effects.push(e)); + const liveOf = new WeakMap>(); + on("effect", (e, live) => { + effects.push(e); + liveOf.set(e, live); + }); + on("rerun", (e, live) => liveOf.set(e, live)); const { setCount } = counter(); // The creation's first callback: timed, no run to join to. expect(effects).toHaveLength(1); @@ -127,19 +148,21 @@ describe("effect records", () => { flush(); expect(effects).toHaveLength(2); const callback = effects[1]; - const rerun = attribution.history().find(r => r.nodeName === "paint")!; + const rerun = attribution.history("rerun").find(r => r.nodeName === "paint")!; expect(callback.run).toBe(rerun.run); expect(callback.nodeId).toBe(rerun.nodeId); expect(callback.at).toBeGreaterThanOrEqual(rerun.at); expect(callback.interaction).toBe(rerun.interaction); expect(callback.interaction).toMatchObject({ kind: "interaction", name: "click" }); - expect(OBSERVE!.subjectOf(callback)).toBe(OBSERVE!.subjectOf(rerun)); + // Both records were delivered beside the same live node. + expect(liveOf.get(callback)).toBeDefined(); + expect(liveOf.get(callback)).toBe(liveOf.get(rerun)); }); it("hands a creation's first callback the interaction that built the node", () => { arm(); const effects: EffectRunEvent[] = []; - attribution.subscribe("effect", e => effects.push(e)); + on("effect", e => effects.push(e)); const [count] = createSignal(1, { name: "count" }); click(() => { createRoot(() => createRenderEffect(count, () => {}, { name: "paint" })); @@ -157,7 +180,7 @@ describe("effect records", () => { createRenderEffect( count, v => { - if (v === 1) attribution.subscribe("effect", e => effects.push(e)); + if (v === 1) on("effect", e => effects.push(e)); }, { name: "paint" } ) @@ -176,7 +199,7 @@ describe("flush records", () => { it("delivers one record per drain with its run counts and the one interaction it served", () => { arm(); const flushes: FlushEvent[] = []; - attribution.subscribe("flush", e => flushes.push(e)); + on("flush", e => flushes.push(e)); const { setCount } = counter(); expect(flushes).toEqual([]); click(() => setCount(1)); @@ -193,7 +216,7 @@ describe("flush records", () => { it("counts creations, and drops the interaction when runs for two share a drain", () => { arm(); const flushes: FlushEvent[] = []; - attribution.subscribe("flush", e => flushes.push(e)); + on("flush", e => flushes.push(e)); const a = counter(); const b = counter(); flushes.length = 0; @@ -226,7 +249,7 @@ describe("flush records", () => { it("marks a drain that parked a transition as held", async () => { arm(); const flushes: FlushEvent[] = []; - attribution.subscribe("flush", e => flushes.push(e)); + on("flush", e => flushes.push(e)); const [page, setPage] = createSignal(1, { name: "page" }); let resolve!: (v: string) => void; const shown: string[] = []; @@ -289,7 +312,11 @@ describe("flight records", () => { it("delivers a landed record per flight with its wall time and interaction", async () => { arm(); const flights: FlightEvent[] = []; - attribution.subscribe("flight", e => flights.push(e)); + const nodes: Computed[] = []; + on("flight", (e, live) => { + flights.push(e); + nodes.push(live); + }); const feed = pagedFeed(); await wait(10); feed.resolve("a"); @@ -298,7 +325,7 @@ describe("flight records", () => { expect(flights[0]).toMatchObject({ nodeName: "posts", outcome: "landed" }); expect(flights[0].durationMs).toBeGreaterThanOrEqual(8); expect(flights[0].interaction).toBeUndefined(); - expect(OBSERVE!.subjectOf(flights[0])).toBeDefined(); + expect(nodes[0]).toBeDefined(); click(() => feed.setPage(2)); flush(); feed.resolve("b"); @@ -312,7 +339,7 @@ describe("flight records", () => { it("delivers an abandoned record when the node's next flight supersedes one in the air", async () => { arm(); const flights: FlightEvent[] = []; - attribution.subscribe("flight", e => flights.push(e)); + on("flight", e => flights.push(e)); const feed = pagedFeed(); feed.setPage(2); flush(); @@ -331,7 +358,7 @@ describe("fallback records", () => { it("delivers one record per showing, when the fallback hides", async () => { arm(); const fallbacks: FallbackEvent[] = []; - attribution.subscribe("fallback", e => fallbacks.push(e)); + on("fallback", e => fallbacks.push(e)); const [page, setPage] = createSignal(1, { name: "page" }); let resolve!: (v: string) => void; const shown: string[] = []; @@ -410,7 +437,7 @@ describe("fallback records", () => { it("a re-arm whose swap the frame never committed (content landed first) is not a showing", async () => { arm(); const fallbacks: FallbackEvent[] = []; - attribution.subscribe("fallback", e => fallbacks.push(e)); + on("fallback", e => fallbacks.push(e)); const t = productPage(); flush(); t.land("product"); @@ -442,7 +469,7 @@ describe("fallback records", () => { it("a re-arm whose swap the frame committed (shell landed first) is one showing, from the commit", async () => { arm(); const fallbacks: FallbackEvent[] = []; - attribution.subscribe("fallback", e => fallbacks.push(e)); + on("fallback", e => fallbacks.push(e)); const t = productPage(); flush(); t.land("product"); @@ -490,7 +517,7 @@ describe("fallback records", () => { ); }); flush(); - attribution.subscribe("fallback", e => fallbacks.push(e)); + on("fallback", e => fallbacks.push(e)); resolve("a"); await until(() => shown.includes("a"), "content"); expect(fallbacks).toEqual([]); diff --git a/packages/signals/tests/attribution-waterfall-eval.test.ts b/packages/signals/tests/attribution-waterfall-eval.test.ts index 805f3cb9b..e044020de 100644 --- a/packages/signals/tests/attribution-waterfall-eval.test.ts +++ b/packages/signals/tests/attribution-waterfall-eval.test.ts @@ -142,7 +142,7 @@ describe("ASYNC_WATERFALL", () => { expectSerialized(events[0], observed); // The fact surface has it too. - const chains = attribution.waterfalls(); + const chains = attribution.history("waterfall"); expect(chains.some(c => c.chain.map(l => l.name).join(">") === "story>author")).toBe(true); void setId; }); @@ -209,7 +209,7 @@ describe("ASYNC_WATERFALL", () => { expect(events).toHaveLength(0); // Not even recorded as a chain fact — the origin test broke the link. - expect(attribution.waterfalls()).toHaveLength(0); + expect(attribution.history("waterfall")).toHaveLength(0); }); it("does not flag an already-settled cached dependent (duration gate)", async () => { diff --git a/packages/signals/tests/attribution.test.ts b/packages/signals/tests/attribution.test.ts index 764e32e0c..8ef4ecacc 100644 --- a/packages/signals/tests/attribution.test.ts +++ b/packages/signals/tests/attribution.test.ts @@ -12,8 +12,20 @@ import { OBSERVE } from "../src/index.js"; import type { AttributionOptions, RerunEvent } from "../src/core/attribution.js"; +import type { RecordListener, RecordType } from "../src/core/dev.js"; +import type { Computed } from "../src/core/types.js"; + +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: (() => void)[] = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} +/** The live node delivered beside each re-run record. */ +const liveOf = new WeakMap>(); afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -23,7 +35,10 @@ afterEach(() => { function collect(opts?: AttributionOptions): RerunEvent[] { attribution.enable({ log: false, ...opts }); const events: RerunEvent[] = []; - attribution.subscribe(e => events.push(e)); + on("rerun", (e, node) => { + events.push(e); + liveOf.set(e, node); + }); return events; } @@ -248,10 +263,10 @@ describe("why-did-this-run attribution", () => { flush(); const last = events.filter(e => e.nodeName === "branchy").at(-1)!; expect(last.depsAdded).toEqual(["b"]); - expect(subscriptions(OBSERVE!.subjectOf(run)!)).toEqual(["flag", "b"]); + expect(subscriptions(liveOf.get(run)!)).toEqual(["flag", "b"]); }); - it("re-run records are serializable: nodeId names the scope, subjectOf hands back the node", () => { + it("re-run records are serializable: nodeId names the scope, the channel hands back the node", () => { const [a, setA] = createSignal(0, { name: "a" }); let double!: () => number; createRoot(() => { @@ -282,16 +297,16 @@ describe("why-did-this-run attribution", () => { expect(doubles[0].nodeId).toBe(doubles[1].nodeId); expect(readers[0].nodeId).toBe(readers[1].nodeId); expect(doubles[0].nodeId).not.toBe(readers[0].nodeId); - // In-process consumers get the node back through the observe surface; + // In-process consumers get the node beside the record on the channel; // the engine's own queries still take the accessor. - const node = OBSERVE!.subjectOf(doubles[0]); + const node = liveOf.get(doubles[0]); expect(node).toBeDefined(); - expect(OBSERVE!.subjectOf(doubles[1])).toBe(node); + expect(liveOf.get(doubles[1])).toBe(node); expect(subscriptions(node!)).toEqual(["a"]); expect(why(double)).toEqual(doubles); expect(why(node)).toEqual(doubles); - // A copy that left the process has no subject. - expect(OBSERVE!.subjectOf(JSON.parse(JSON.stringify(doubles[0])))).toBeUndefined(); + // A copy that left the process names nothing the engine can look up. + expect(why(JSON.parse(JSON.stringify(doubles[0])))).toEqual([]); }); it("warns on hot scopes, once per window", () => { @@ -707,11 +722,11 @@ describe("why-did-this-run attribution", () => { ); flush(); const events: RerunEvent[] = []; - attribution.subscribe(e => events.push(e)); + on("rerun", e => events.push(e)); setN(1); flush(); expect(events).toHaveLength(0); - expect(attribution.history()).toHaveLength(0); + expect(attribution.history("rerun")).toHaveLength(0); }); }); @@ -734,9 +749,9 @@ describe("shared engine: holds, releases, layered options", () => { const first: RerunEvent[] = []; const second: RerunEvent[] = []; const releaseFirst = attribution.enable({ log: false }); - attribution.subscribe(e => first.push(e)); + on("rerun", e => first.push(e)); const releaseSecond = attribution.enable({ log: false }); - attribution.subscribe(e => second.push(e)); + on("rerun", e => second.push(e)); setN(1); flush(); @@ -751,13 +766,15 @@ describe("shared engine: holds, releases, layered options", () => { expect(first).toHaveLength(2); expect(second).toHaveLength(2); - // The last one leaves: uninstalled, listeners cleared. + // The last one leaves: the engine uninstalls, so nothing further is + // recorded — the channel subscriptions themselves are the consumers' to + // release and are untouched. releaseSecond(); setN(3); flush(); expect(first).toHaveLength(2); expect(second).toHaveLength(2); - expect(attribution.history()).toHaveLength(0); + expect(attribution.history("rerun")).toHaveLength(0); }); it("opens a fresh window on every enable without uninstalling", () => { @@ -765,15 +782,15 @@ describe("shared engine: holds, releases, layered options", () => { const release = attribution.enable({ log: false }); setN(1); flush(); - expect(attribution.history()).toHaveLength(1); + expect(attribution.history("rerun")).toHaveLength(1); // A second consumer arrives (say, a capture): it reads back only what // happens from here on. const releaseCapture = attribution.enable({ log: false }); - expect(attribution.history()).toHaveLength(0); + expect(attribution.history("rerun")).toHaveLength(0); setN(2); flush(); - expect(attribution.history()).toHaveLength(1); + expect(attribution.history("rerun")).toHaveLength(1); releaseCapture(); release(); }); @@ -789,7 +806,7 @@ describe("shared engine: holds, releases, layered options", () => { const logs = () => logged.mock.calls.length; // A track asks for no log. const releaseTrack = attribution.enable({ log: false, hotRuns: false, hotTime: false }); - attribution.subscribe(e => events.push(e)); + on("rerun", e => events.push(e)); setN(1); flush(); expect(logs()).toBe(0); @@ -889,16 +906,16 @@ describe("shared engine: holds, releases, layered options", () => { // once — the pre-token idiom — leaves nothing behind. const release = attribution.enable({ log: false }); attribution.enable({ log: false }); - attribution.subscribe(e => events.push(e)); + on("rerun", e => events.push(e)); attribution.disable(); setN(1); flush(); expect(events).toHaveLength(0); - expect(attribution.history()).toHaveLength(0); + expect(attribution.history("rerun")).toHaveLength(0); release(); // a release after the teardown is a no-op setN(2); flush(); - expect(attribution.history()).toHaveLength(0); + expect(attribution.history("rerun")).toHaveLength(0); }); it("disable without a matching enable is a full, idempotent reset", () => { @@ -906,7 +923,7 @@ describe("shared engine: holds, releases, layered options", () => { attribution.disable(); attribution.disable(); const events: RerunEvent[] = []; - attribution.subscribe(e => events.push(e)); + on("rerun", e => events.push(e)); setN(1); flush(); expect(events).toHaveLength(0); diff --git a/packages/signals/tests/dist-artifacts.test.ts b/packages/signals/tests/dist-artifacts.test.ts index 8071b5b78..7640f7f80 100644 --- a/packages/signals/tests/dist-artifacts.test.ts +++ b/packages/signals/tests/dist-artifacts.test.ts @@ -48,7 +48,8 @@ function expectObserveLive(mod: Tier) { expect(typeof observe.attribution.withOrigin).toBe("function"); expect(observe.attribution.installed).toBeNull(); expect(observe.attribution.enable).toBeUndefined(); - expect(typeof observe.subjectOf).toBe("function"); + // The live subject rides beside each record and diagnostic; no lookup. + expect(observe.subjectOf).toBeUndefined(); } /** @@ -61,17 +62,20 @@ function expectEngineDrivesCore(core: any, engine: Engine) { const { attribution } = engine; const observe = core.OBSERVE; attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false }); + const offs: (() => void)[] = []; try { expect(observe.attribution.installed).not.toBeNull(); + // The records arrive on the core's channel — the one this engine emits + // into, or a second core would be delivering into an empty room. const runs: any[] = []; - attribution.subscribe((e: any) => runs.push(e)); + offs.push(observe.records.subscribe("rerun", (e: any) => runs.push(e))); // The timeline records ride core hooks of their own: `flushStart` in the // scheduler, `effectRunStart`/`End` around the callback — both must be // live in the observe core, not only in dev. const flushes: any[] = []; - attribution.subscribe("flush", (e: any) => flushes.push(e)); + offs.push(observe.records.subscribe("flush", (e: any) => flushes.push(e))); const effects: any[] = []; - attribution.subscribe("effect", (e: any) => effects.push(e)); + offs.push(observe.records.subscribe("effect", (e: any) => effects.push(e))); const setCount = core.createRoot(() => { const [count, set] = core.createSignal(0, { name: "count" }); core.createEffect(count, () => {}, { name: "reader" }); @@ -90,7 +94,10 @@ function expectEngineDrivesCore(core: any, engine: Engine) { expect(rerun.causes[0].origin).toMatchObject({ kind: "navigation", name: "/go" }); expect(rerun.interaction).toMatchObject({ kind: "interaction", name: "click" }); // The drain's flushEnd reached the engine: the navigation settled. - expect(attribution.navigations()[0]).toMatchObject({ name: "/go", outcome: "committed" }); + expect(attribution.history("navigation")[0]).toMatchObject({ + name: "/go", + outcome: "committed" + }); // …and its flushStart: the drain is one record, serving the click. expect(flushes.at(-1)).toMatchObject({ runs: 1, held: false }); expect(flushes.at(-1).interaction).toMatchObject({ kind: "interaction", name: "click" }); @@ -100,6 +107,7 @@ function expectEngineDrivesCore(core: any, engine: Engine) { expect(callback.nodeId).toBe(rerun.nodeId); expect(callback.interaction).toBe(rerun.interaction); } finally { + for (const off of offs) off(); attribution.disable(); } expect(observe.attribution.installed).toBeNull(); @@ -109,10 +117,10 @@ function expectEngineDrivesCore(core: any, engine: Engine) { function expectEngineInert(engine: Engine) { const { attribution } = engine; expect(() => attribution.enable()).not.toThrow(); - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); expect(engine.costs()).toEqual({ scopes: [], writes: [] }); - expect(attribution.holds()).toEqual([]); - expect(attribution.navigations()).toEqual([]); + expect(attribution.history("hold")).toEqual([]); + expect(attribution.history("navigation")).toEqual([]); expect(engine.feedback()).toEqual({ sources: [], interactions: [], @@ -460,16 +468,24 @@ describe("@solidjs/signals engine per tier", () => { // if `_name` were not reserved the label would land on a property // ownerPath never reads. const capture = OBSERVE.diagnostics.capture(); + const subjects: unknown[] = []; + const off = OBSERVE.diagnostics.subscribe((_: unknown, subject: unknown) => + subjects.push(subject) + ); + let emitted: unknown; createRoot(() => { const owner = getOwner(); owner._name = ""; + emitted = owner; OBSERVE.diagnostics.emit( { code: "INVARIANT_VIOLATION", kind: "error", severity: "error", message: "probe" }, owner ); }); + off(); const [event] = capture.stop(); expect(event.ownerPath).toEqual([""]); - expect(OBSERVE.subjectOf(event)).toBeDefined(); + // The subject rides beside the event to every listener. + expect(subjects).toEqual([emitted]); }); }); diff --git a/packages/signals/tests/observe-exclude.test.ts b/packages/signals/tests/observe-exclude.test.ts index dc84cbed5..8b8cf150d 100644 --- a/packages/signals/tests/observe-exclude.test.ts +++ b/packages/signals/tests/observe-exclude.test.ts @@ -23,7 +23,10 @@ import { import type { DiagnosticEvent } from "../src/core/dev.js"; import type { Owner } from "../src/core/types.js"; +const offs: (() => void)[] = []; + afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -113,7 +116,7 @@ describe("OBSERVE.exclude", () => { it("forgets an interaction whose only writes went to the panel's own store", () => { arm(); const delivered: unknown[] = []; - attribution.subscribe("interaction", e => delivered.push(e)); + offs.push(OBSERVE!.records.subscribe("interaction", e => delivered.push(e))); const { result: setPanel } = excludedRoot(() => { const [panel, setPanel] = createStore<{ items: number[] }>({ items: [] }); // The panel renders its list, so the store has live nodes to write. @@ -132,7 +135,7 @@ describe("OBSERVE.exclude", () => { setPanel(s => void s.items.push(1)) ); flush(); - expect(attribution.interactions()).toHaveLength(0); + expect(attribution.history("interaction")).toHaveLength(0); expect(delivered).toHaveLength(0); // A click that also writes the app is the app's: recorded, with the @@ -143,9 +146,9 @@ describe("OBSERVE.exclude", () => { setApp(1); }); flush(); - expect(attribution.interactions()).toHaveLength(1); + expect(attribution.history("interaction")).toHaveLength(1); expect(delivered).toHaveLength(1); - expect(attribution.interactions()[0].writes).toBe(1); + expect(attribution.history("interaction")[0].writes).toBe(1); }); it("records no runs for the panel's computations", () => { @@ -156,10 +159,10 @@ describe("OBSERVE.exclude", () => { flush(); OBSERVE!.attribution.withInteraction({ type: "click" }, () => setTick(1)); flush(); - const names = attribution.history().map(r => r.nodeName); + const names = attribution.history("rerun").map(r => r.nodeName); expect(names).toEqual(["app"]); expect(costs().scopes.map(s => s.name)).toEqual(["app"]); - const [click] = attribution.interactions(); + const [click] = attribution.history("interaction"); expect(click.runs).toBe(1); }); }); diff --git a/packages/signals/tests/observe-records.test.ts b/packages/signals/tests/observe-records.test.ts index 8d6ee715f..721dc515e 100644 --- a/packages/signals/tests/observe-records.test.ts +++ b/packages/signals/tests/observe-records.test.ts @@ -1,9 +1,11 @@ /** * `OBSERVE.records` — the one channel every runtime record rides, on either - * platform: solid-js's `"boundary"`, @solidjs/web's `"invocation"`, `"frame"` - * and `"call"`. The core owns the container and knows no record type; the - * runtimes declare theirs onto it (type-level, `RecordTypes` / - * `HostRecordTypes`) and emit through it. + * platform: the attribution engine's `"rerun"`, `"hold"`, `"interaction"` and + * the rest of its timeline, solid-js's `"boundary"`, @solidjs/web's + * `"invocation"`, `"frame"` and `"call"`. The core owns the container and + * knows no record type beyond declaring the engine's; the runtimes declare + * theirs onto it (type-level, `RecordTypes` / `HostRecordTypes`) and emit + * through it. * * Claims under test: * - one per PROCESS, registered on `globalThis`: a second copy of the core @@ -14,6 +16,10 @@ * record built, not even a clock read; * - a listener cannot alter the emit: it is snapshotted per emit, a * throwing listener is reported and the rest still run. + * + * The emit allocates nothing: the listener list is copied on subscribe and + * unsubscribe, never on delivery, so the loop walks the array it started + * with; one try/catch around it resumes past a throwing listener. */ import { afterEach, describe, expect, it, vi } from "vitest"; import { OBSERVE } from "../src/index.js"; @@ -81,6 +87,36 @@ describe("OBSERVE.records", () => { expect(String(error.mock.calls[0][0])).toContain("listener broke"); }); + it("every throwing listener is reported and delivery resumes after each", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const seen: string[] = []; + on("probe", () => { + throw new Error("first"); + }); + on("probe", () => seen.push("between")); + on("probe", () => { + throw new Error("second"); + }); + on("probe", () => seen.push("after")); + records.emit("probe", {}, {}); + expect(seen).toEqual(["between", "after"]); + expect(error.mock.calls.map(c => String(c[0]))).toEqual([ + expect.stringContaining("first"), + expect.stringContaining("second") + ]); + }); + + it("the same listener subscribed twice is one subscription", () => { + const seen: string[] = []; + const listener = () => seen.push("a"); + const off1 = on("probe", listener); + on("probe", listener); + records.emit("probe", {}, {}); + expect(seen).toEqual(["a"]); + off1(); + expect(records.observed("probe")).toBe(false); + }); + it("the listener set is snapshotted per emit: subscribing or unsubscribing inside does not affect this delivery", () => { const seen: string[] = []; let offB: () => void = () => {}; diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 0ed672ba0..56a7ab4e1 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -584,8 +584,7 @@ describe("pay-for-use tree-shaking (#2883)", () => { const RECORDS_CONSUMER = ` import { attribution } from "attr"; attribution.enable({ log: false }); - attribution.subscribe(e => console.log(e.nodeName)); - export const holds = attribution.holds; + export const holds = () => attribution.history("hold"); `; const FEEDBACK_CONSUMER = ` import { attribution, feedback } from "attr"; diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md index a3f5d81d4..2eba4d08c 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -252,7 +252,7 @@ culprit is on one of them. Look for, in order: Do NOT reach for `dispose()` on the app root or a periodic sweep; the fix is ownership — create the thing under the owner whose lifetime it should share. `graphSize()` from `solid-js/attribution` gives the count on demand; -`subscribe("graph", …)` gives it at every navigation's settle. +`OBSERVE.records.subscribe("graph", …)` gives it at every navigation's settle. ### HOT_SCOPE_RERUNS @@ -312,7 +312,7 @@ counted here; see `costs().scopes[].wastedMs` for the total per scope. Async flights ran in sequence when they might have run in parallel: each named flight provably could not start until the previous one resolved, and each took real time (the per-link durations are in the message/data). Read -the chain from `attribution.waterfalls()` if you need more than the +the chain from `attribution.history("waterfall")` if you need more than the warning shows. Repairs, in order of preference: 1. If a later request does not need the earlier response, derive both from @@ -437,9 +437,10 @@ Thresholds sit at the strict end of the band on purpose: the engine measures to the commit, not the paint, so every number is a floor on what the user saw. From `holds.infoMs` (default 100ms — past "feels instant") the event is `info`-severity, structured channel only; from `holds.warnMs` (default 200ms — -the INP "good" ceiling) it reaches the console. `attribution.holds()` +the INP "good" ceiling) it reaches the console. `attribution.history("hold")` lists every hold (acknowledged or not) with what was held, what blocked it, -and which affordances answered it. When the silent hold's tail also crossed +and which affordances answered it; each carries the engine's verdicts as +`silent`/`long` fields. When the silent hold's tail also crossed the long-hold threshold (`data.long: true`) the message carries the `LONG_HOLD` repair as well — the fallback is the honest UI at that length. @@ -479,7 +480,7 @@ nothing before its first `await`. No hold opened (there was no write to hold), so `SILENT_HOLD` could not see the wait, yet from the user's side the click did nothing for `data.continuationMs` (handler return → the promise settling). The interaction record stayed open for the wait, so -`attribution.interactions()` shows it with `continuationMs` set. Same +`attribution.history("interaction")` shows it with `continuationMs` set. Same thresholds as `SILENT_HOLD` (`holds.infoMs`/`warnMs`; off with `holds: false`); the wait is capped at 10s for a promise that never settles (`data.capped`). Two repairs, in order of preference: @@ -622,7 +623,7 @@ them as `artifact.attribution.feedback` / `.costs` already. silent needs the affordances above where the route's data renders; a route with many superseded navigations is one users give up on — make its data fast or preload it on hover/intent; a route that is always `redirected` - into is paying a hop the link could skip. `attribution.navigations()` + into is paying a hop the link could skip. `attribution.history("navigation")` lists each navigation with its `outcome`, its `redirects` (the abandoned destinations) and, when held, the `HoldEvent` itself. - `flights` — one row per async source: `flights` started, `landed`, diff --git a/packages/solid/src/console-footer.ts b/packages/solid/src/console-footer.ts index 9df277b85..4b911b452 100644 --- a/packages/solid/src/console-footer.ts +++ b/packages/solid/src/console-footer.ts @@ -42,8 +42,8 @@ export function installConsoleFooter(dev: Dev): void { return event.kind === "perf" || event.kind === "graph" || event.kind === "responsiveness" ? base + `\n[${event.code}] deeper evidence: import { attribution } from "solid-js/attribution"; ` + - `attribution.enable() explains every re-run — why-chains, costs(), waterfalls(), ` + - `holds(), feedback() — agent loop: ` + + `attribution.enable() explains every re-run — why-chains, costs(), feedback(), ` + + `history("hold" | "waterfall") — agent loop: ` + `node_modules/@solidjs/diagnostics/skills/agent-loops/SKILL.md — ` + `${SKILLS_URL}/diagnostics/skills/agent-loops/SKILL.md` : base; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 404b7f29e..5f22b0e96 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -225,8 +225,6 @@ export type { export type { RecoveryEvent, RecoveryLive, RecoveryListener } from "./recovery.js"; export type { Acknowledgement, - AttributionRecords, - AttributionRecordType, ChangeOrigin, ChangeRecord, CreateEvent, diff --git a/packages/solid/test/refresh.spec.ts b/packages/solid/test/refresh.spec.ts index 4aef23567..71c9a17b9 100644 --- a/packages/solid/test/refresh.spec.ts +++ b/packages/solid/test/refresh.spec.ts @@ -6,7 +6,8 @@ import { createSignal, flush, getOwner, - ownerPath + ownerPath, + OBSERVE } from "../src/index.js"; import { attribution } from "../src/attribution.js"; import { @@ -125,8 +126,10 @@ describe("$$component proxy owner paths", () => { const release = attribution.enable({ log: false }); const creates: [string, string][] = []; const reruns: string[] = []; - const offCreate = attribution.subscribe("create", e => creates.push([e.nodeName, e.nodeKind])); - const offRerun = attribution.subscribe("rerun", e => reruns.push(e.nodeName)); + const offCreate = OBSERVE!.records.subscribe("create", e => + creates.push([e.nodeName, e.nodeKind]) + ); + const offRerun = OBSERVE!.records.subscribe("rerun", e => reruns.push(e.nodeName)); try { const first = executeModule(hot, { Counter: { diff --git a/packages/universal/test/diagnostic-names.spec.js b/packages/universal/test/diagnostic-names.spec.js index d9658b702..aa02633c2 100644 --- a/packages/universal/test/diagnostic-names.spec.js +++ b/packages/universal/test/diagnostic-names.spec.js @@ -5,18 +5,23 @@ // renderer-owned effect gets a stable "renderer ..." fallback (dev only — // the "_SOLID_DEV_" constant folds the fallbacks out of production builds). import * as r from "./custom.js"; -import { createRoot, createSignal, flush } from "solid-js"; +import { OBSERVE, createRoot, createSignal, flush } from "solid-js"; import { attribution } from "solid-js/attribution"; +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs = []; + /** Enable attribution quietly and collect every rerun event. */ function collect() { attribution.enable({ log: false }); const events = []; - attribution.subscribe(e => events.push(e)); + offs.push(OBSERVE.records.subscribe("rerun", e => events.push(e))); return events; } afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); }); diff --git a/packages/web/performance-tracks/src/index.ts b/packages/web/performance-tracks/src/index.ts index e4fb9caab..db3a8ec86 100644 --- a/packages/web/performance-tracks/src/index.ts +++ b/packages/web/performance-tracks/src/index.ts @@ -5,8 +5,9 @@ * The attribution engine (`solid-js/attribution`) already knows why every * effect and memo ran, what each click cost until the screen settled, which * holds the user waited in and what they waited on, which route a - * navigation was, and — through `OBSERVE.records` — every server-function - * call the browser made. This module is a second RENDERING of those same + * navigation was; the web runtime knows every server-function call the + * browser made — and all of it arrives on one channel, `OBSERVE.records`. + * This module is a second RENDERING of those same * records: where `@solidjs/diagnostics` writes them into an artifact an * agent reads, this paints them as custom tracks (group `Solid`) beside * Chrome's own main-thread and network tracks, using the panel's @@ -36,6 +37,7 @@ import type { ChangeRecord, CreateEvent, DiagnosticEvent, + DiagnosticSubject, EffectRunEvent, FallbackEvent, FlightEvent, @@ -44,7 +46,6 @@ import type { HoldEvent, InteractionEvent, NavigationEvent, - Observe, RerunEvent } from "solid-js"; import type { CallEvent, CallLive, FrameEvent } from "@solidjs/web"; @@ -52,8 +53,6 @@ import { attribution, formatOrigin, formatRerun, - isLongHold, - isSilentHold, type AttributionOptions } from "solid-js/attribution"; @@ -231,28 +230,29 @@ export function enablePerformanceTracks(options: PerformanceTracksOptions = {}): const minMs = options.minMs ?? (IS_DEV ? 0 : 0.05); const scrub = options.scrub ?? !IS_DEV; - const painter = new Painter(observe, emitter, minMs, scrub); + const painter = new Painter(emitter, minMs, scrub); const server = new ServerSpans(emitter); server.start(); + const { records } = observe; const releases = [ () => server.dispose(), attribution.enable({ log: false, ...options.attribution }), - attribution.subscribe("rerun", e => painter.rerun(e)), - attribution.subscribe("create", e => painter.create(e)), - attribution.subscribe("effect", e => painter.effect(e)), - attribution.subscribe("flush", e => painter.flush(e)), - attribution.subscribe("flight", e => painter.flight(e)), - attribution.subscribe("fallback", e => painter.fallback(e)), - attribution.subscribe("interaction", e => painter.interaction(e)), - attribution.subscribe("hold", e => painter.hold(e)), - attribution.subscribe("navigation", e => painter.navigation(e)), - observe.records.subscribe("call", (e, live) => { + records.subscribe("rerun", (e, node) => painter.rerun(e, node)), + records.subscribe("create", (e, node) => painter.create(e, node)), + records.subscribe("effect", (e, node) => painter.effect(e, node)), + records.subscribe("flush", e => painter.flush(e)), + records.subscribe("flight", (e, node) => painter.flight(e, node)), + records.subscribe("fallback", (e, subtree) => painter.fallback(e, subtree)), + records.subscribe("interaction", e => painter.interaction(e)), + records.subscribe("hold", e => painter.hold(e)), + records.subscribe("navigation", e => painter.navigation(e)), + records.subscribe("call", (e, live) => { painter.call(e); server.call(e, live); }), - observe.records.subscribe("frame", e => painter.frame(e)), - observe.diagnostics.subscribe(e => painter.diagnostic(e)) + records.subscribe("frame", e => painter.frame(e)), + observe.diagnostics.subscribe((e, subject) => painter.diagnostic(e, subject)) ]; const active: Instance = { holders: 0, @@ -431,7 +431,6 @@ class Painter { */ private readonly names = new Map(); constructor( - private readonly observe: Observe, private readonly emit: Emitter, private readonly minMs: number, private readonly scrub: boolean @@ -453,10 +452,9 @@ class Painter { * depends on; a deep one is a chain of memos; a `warning` node with no * dependants after it is the equality cutoff doing its job. */ - rerun(event: RerunEvent): void { + rerun(event: RerunEvent, subject: DiagnosticSubject): void { collectRoots(event.causes, this.roots); if (!event.changed) this.unchanged++; - const subject = this.observe.subjectOf(event); const node = describe(event.nodeName, ownerPath(subject)); this.names.set(event.nodeId, node.short); if (event.totalMs < this.minMs) return; @@ -513,8 +511,7 @@ class Painter { * `Propagation` too: a wave that builds nodes (a `` flipping, a * `` growing) shows what it mounted beside what it re-ran. */ - create(event: CreateEvent): void { - const subject = this.observe.subjectOf(event); + create(event: CreateEvent, subject: DiagnosticSubject): void { const node = describe(event.nodeName, ownerPath(subject)); this.names.set(event.nodeId, node.short); if (event.totalMs < this.minMs) return; @@ -553,9 +550,8 @@ class Painter { * palette so the two halves read apart. On `Propagation` it is the leaf * of the wave: where the write finally reached the screen. */ - effect(event: EffectRunEvent): void { + effect(event: EffectRunEvent, subject: DiagnosticSubject): void { if (event.durationMs < this.minMs) return; - const subject = this.observe.subjectOf(event); const node = describe(event.nodeName, ownerPath(subject)); let properties: Properties | undefined; if (this.rich) { @@ -584,7 +580,7 @@ class Painter { * only the code, kind and owner are carried (a responsiveness finding's * sentence names the element the user hit). */ - diagnostic(event: DiagnosticEvent): void { + diagnostic(event: DiagnosticEvent, subject: DiagnosticSubject | undefined): void { const owner = event.ownerPath?.join(" › "); const label = event.ownerPath !== undefined @@ -631,15 +627,15 @@ class Painter { }; } } - this.emit.mark(label, color, tooltip, properties, issue, taskOf(this.observe.subjectOf(event))); + this.emit.mark(label, color, tooltip, properties, issue, taskOf(subject)); } /** * The identity properties every node span carries in rich mode: the * runtime's full owner path (the label folds flow internals and composed * primitives — this is the unfolded truth), the folded node's runtime - * name when there is one, and the engine's node id (what `why()` and - * `subjectOf` key on). + * name when there is one, and the engine's node id (what `why()` keys + * on, and what joins a node's records offline). */ private identity(properties: Properties, node: Described, nodeId: number): void { if (node.path !== undefined) properties.push(["Owner path", node.path]); @@ -706,7 +702,7 @@ class Painter { * path; `warning` when it was abandoned (superseded before it landed — * the re-ask storm's signature). */ - flight(event: FlightEvent): void { + flight(event: FlightEvent, subject: DiagnosticSubject): void { const node = describe(event.nodeName, event.ownerPath); let properties: Properties | undefined; if (this.rich) { @@ -727,7 +723,7 @@ class Painter { event.outcome === "abandoned" ? "warning" : "secondary", undefined, properties, - taskOf(this.observe.subjectOf(event)) + taskOf(subject) ); } @@ -735,7 +731,7 @@ class Painter { * `Async`: a loading boundary's fallback, show → hide, named by the * boundary from its nearest component (`fallback › `). */ - fallback(event: FallbackEvent): void { + fallback(event: FallbackEvent, subject: DiagnosticSubject | undefined): void { const path = event.ownerPath; const label = `fallback${path !== undefined ? ` ${nearest(path).join(" › ")}` : ""}`; let properties: Properties | undefined; @@ -754,7 +750,7 @@ class Painter { "tertiary", undefined, properties, - taskOf(this.observe.subjectOf(event)) + taskOf(subject) ); } @@ -804,7 +800,7 @@ class Painter { if (event.settledMs === undefined || outcome === undefined || outcome === "idle") return; const settleEnd = event.at + event.settledMs; if (settleEnd <= handlerEnd) return; - const silent = outcome === "held" && event.holds.some(isSilentHold); + const silent = outcome === "held" && event.holds.some(h => h.silent); this.emit.span( outcome, handlerEnd, @@ -823,8 +819,7 @@ class Painter { * verdicts, not thresholds of this adapter's. */ hold(event: HoldEvent): void { - const long = isLongHold(event); - const silent = isSilentHold(event); + const { long, silent } = event; const what = event.blockers.length > 0 ? event.blockers.join(", ") : "a transition"; const label = event.origin !== undefined @@ -1137,7 +1132,7 @@ function describe(nodeName: string, path: string[] | undefined): Described { * exists only there; elsewhere the walk finds nothing and costs a few * pointer reads per painted span. */ -function taskOf(subject: ReturnType): ConsoleTask | undefined { +function taskOf(subject: DiagnosticSubject | undefined): ConsoleTask | undefined { if (!IS_DEV || !subject) return undefined; let owner: any = "_parent" in subject ? subject : (subject as any)._owner; for (; owner != null; owner = owner._parent) { diff --git a/packages/web/test/client-records.spec.tsx b/packages/web/test/client-records.spec.tsx index f17cac628..37ac5499d 100644 --- a/packages/web/test/client-records.spec.tsx +++ b/packages/web/test/client-records.spec.tsx @@ -216,8 +216,10 @@ describe("the call record's origin", () => { attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false }); const interactions: InteractionEvent[] = []; const navigations: NavigationEvent[] = []; - attribution.subscribe("interaction", e => interactions.push(e)); - attribution.subscribe("navigation", e => navigations.push(e)); + unsubscribes.push( + OBSERVE!.records.subscribe("interaction", e => interactions.push(e)), + OBSERVE!.records.subscribe("navigation", e => navigations.push(e)) + ); return { interactions, navigations }; } diff --git a/packages/web/test/diagnostic-element-ref.spec.tsx b/packages/web/test/diagnostic-element-ref.spec.tsx index 941918393..fd648ce59 100644 --- a/packages/web/test/diagnostic-element-ref.spec.tsx +++ b/packages/web/test/diagnostic-element-ref.spec.tsx @@ -13,7 +13,10 @@ import { attribution } from "solid-js/attribution"; * element as a second argument — a live reference beside the message. */ +// The channel's subscriptions are the consumer's — not dropped by `disable()`. +const offs: Array<() => void> = []; afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -33,9 +36,9 @@ describe("diagnostic element references", () => { document.body.appendChild(container); const dispose = render(() =>
, container); flush(); - // Records never carry the node; the observe surface hands it back in-process. + // Records never carry the node; the channel delivers it beside each record. const nodes: any[] = []; - attribution.subscribe(e => nodes.push(OBSERVE!.subjectOf(e))); + offs.push(OBSERVE!.records.subscribe("rerun", (e, live) => nodes.push(live))); for (let i = 0; i < 6; i++) { setCls(`c${i}`); diff --git a/packages/web/test/interaction-provenance.spec.tsx b/packages/web/test/interaction-provenance.spec.tsx index 85be1123b..aef64f1ad 100644 --- a/packages/web/test/interaction-provenance.spec.tsx +++ b/packages/web/test/interaction-provenance.spec.tsx @@ -4,7 +4,8 @@ */ import { afterEach, describe, expect, test, vi } from "vitest"; import { render } from "@solidjs/web"; -import { createEffect, createSignal, flush } from "solid-js"; +import { OBSERVE, createEffect, createSignal, flush } from "solid-js"; +import type { RecordListener, RecordType } from "solid-js"; import { attribution } from "solid-js/attribution"; /** @@ -14,7 +15,15 @@ import { attribution } from "solid-js/attribution"; * a handler is stamped with the event and a description of what was hit. */ +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: Array<() => void> = []; +function on(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} + afterEach(() => { + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -41,7 +50,7 @@ describe("interaction provenance", () => { ); }, container); flush(); - attribution.subscribe(e => { + on("rerun", e => { if (e.nodeName === "reader") latest = e; }); @@ -80,7 +89,7 @@ describe("interaction provenance", () => { ); }, container); flush(); - attribution.subscribe(e => { + on("rerun", e => { if (e.nodeName === "reader") seen.push(e.causes[0].origin); }); @@ -112,7 +121,7 @@ describe("interaction provenance", () => { container ); flush(); - attribution.subscribe("interaction", e => (interaction = e)); + on("interaction", e => (interaction = e)); // The browser created the event 30ms before the handler ran (a busy main // thread): `PerformanceEventTiming.startTime` would carry this value. @@ -145,7 +154,7 @@ describe("interaction provenance", () => { container ); flush(); - attribution.subscribe("interaction", e => (interaction = e)); + on("interaction", e => (interaction = e)); const ev = new MouseEvent("click", { bubbles: true }); expect(ev.timeStamp).toBeGreaterThan(performance.now()); // jsdom: Date.now() @@ -170,7 +179,7 @@ describe("interaction provenance", () => { return setQ(e.currentTarget.value)} />; }, container); flush(); - attribution.subscribe(e => { + on("rerun", e => { if (e.nodeName === "reader") origin = e.causes[0].origin; }); const input = container.querySelector("input")!; diff --git a/packages/web/test/observe.type-tests.ts b/packages/web/test/observe.type-tests.ts index 30f16afd7..a6e357ab7 100644 --- a/packages/web/test/observe.type-tests.ts +++ b/packages/web/test/observe.type-tests.ts @@ -45,9 +45,25 @@ declare const observe: NonNullable; // interface. observe.records satisfies Records; -// The catalogue is the union of what the loaded runtimes declared — through -// both augmentation paths. -type Declared = "boundary" | "recovery" | "invocation" | "call" | "frame"; +// The catalogue is the union of the attribution engine's records (declared by +// the core beside the channel) and what the loaded runtimes declared — +// through both augmentation paths. +type Declared = + | "rerun" + | "create" + | "effect" + | "flush" + | "flight" + | "fallback" + | "interaction" + | "hold" + | "navigation" + | "graph" + | "boundary" + | "recovery" + | "invocation" + | "call" + | "frame"; const declared: Declared = "boundary" as RecordType; declared; const known: RecordType = "call" as Declared; diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index d27e65848..01aee8715 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -32,7 +32,8 @@ import { createSignal, flush } from "solid-js"; -import { attribution, formatOrigin, formatRerun, isLongHold } from "solid-js/attribution"; +import type { RecordListener, RecordLive, RecordType } from "solid-js"; +import { attribution, formatOrigin, formatRerun } from "solid-js/attribution"; import type { InteractionEvent, RerunEvent, HoldEvent } from "solid-js/attribution"; import type { CallEvent } from "@solidjs/web"; import { enablePerformanceTracks } from "../performance-tracks/src/index.js"; @@ -49,8 +50,17 @@ interface Measure { } const disposers: Array<() => void> = []; +// The engine's records arrive on the channel, whose subscriptions are the +// consumer's — not dropped by `disable()` — so each test's are released here. +const offs: Array<() => void> = []; +function listen(type: K, listener: RecordListener): void { + offs.push(OBSERVE!.records.subscribe(type, listener)); +} +/** The live node delivered beside each re-run record. */ +const liveOf = new WeakMap>(); afterEach(() => { for (const dispose of disposers.splice(0)) dispose(); + for (const off of offs.splice(0)) off(); attribution.disable(); flush(); vi.restoreAllMocks(); @@ -239,9 +249,12 @@ function records() { const reruns: RerunEvent[] = []; const interactions: InteractionEvent[] = []; const holds: HoldEvent[] = []; - attribution.subscribe("rerun", e => reruns.push(e)); - attribution.subscribe("interaction", e => interactions.push(e)); - attribution.subscribe("hold", e => holds.push(e)); + listen("rerun", (e, live) => { + reruns.push(e); + liveOf.set(e, live); + }); + listen("interaction", e => interactions.push(e)); + listen("hold", e => holds.push(e)); return { reruns, interactions, holds }; } @@ -837,7 +850,7 @@ describe("enablePerformanceTracks", () => { settledMs: 10 }); expect(hold).toMatchObject({ at: clicked, holdMs: 10, tailMs: 10 }); - expect(isLongHold(hold)).toBe(false); // 10ms tail: under `longHolds.infoMs` + expect(hold.long).toBe(false); // 10ms tail: under `longHolds.infoMs` const [holdSpan] = on("Holds").filter(m => m.label !== "Holds"); expect(holdSpan).toMatchObject({ label: "waiting on posts", @@ -906,7 +919,7 @@ describe("enablePerformanceTracks", () => { resolve("c"); await until(() => shown.includes("c-p3")); - expect(holds.map(h => [h.holdMs, h.tailMs, isLongHold(h)])).toEqual([ + expect(holds.map(h => [h.holdMs, h.tailMs, h.long])).toEqual([ [499, 499, false], [500, 500, true] ]); @@ -1371,7 +1384,7 @@ describe("enablePerformanceTracks", () => { flush(); expect(seen.filter(m => m.label.endsWith("reader"))).toHaveLength(2); // The engine hold was taken once and is released with the instance. - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); }); test("scrub: no value previews, no element text except on a button or a link", () => { @@ -1413,7 +1426,7 @@ describe("enablePerformanceTracks", () => { quiet(); attribution.enable({ log: false, hotRuns: false, hotTime: false }); const other: RerunEvent[] = []; - attribution.subscribe("rerun", e => other.push(e)); + listen("rerun", e => other.push(e)); const disable = enable(); const [n, setN] = createSignal(0, { name: "n" }); @@ -1474,7 +1487,7 @@ describe("enablePerformanceTracks", () => { flush(); setN(1); flush(); - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); }); test("a finding is a marker on the Timings track, an issue for the Insights sidebar when it warns", () => { @@ -1571,6 +1584,7 @@ describe("enablePerformanceTracks", () => { const { created } = tasks(); const { on, marks } = measures(); enable(); + const { reruns } = records(); const [n, setN] = createSignal(0, { name: "n" }); function Row() { createRenderEffect(n, () => {}, { name: "reader" }); @@ -1599,7 +1613,7 @@ describe("enablePerformanceTracks", () => { ).toBe(true); // A finding about a node under the component too. quiet(); - const subject = OBSERVE!.subjectOf(attribution.history().find(r => r.nodeName === "reader")!)!; + const subject = liveOf.get(reruns.find(r => r.nodeName === "reader")!)!; OBSERVE!.diagnostics.emit( { code: "HOT_SCOPE_RERUNS", kind: "perf", severity: "warn", message: "hot" }, subject @@ -1702,7 +1716,7 @@ describe("enablePerformanceTracks", () => { flush(); setN(1); flush(); - expect(attribution.history()).toEqual([]); // the engine was never enabled + expect(attribution.history("rerun")).toEqual([]); // the engine was never enabled disable(); }); }); @@ -1724,7 +1738,7 @@ describe("the production artifact", () => { flush(); setN(1); flush(); - expect(attribution.history()).toEqual([]); + expect(attribution.history("rerun")).toEqual([]); disable(); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 8551ff127..3746871d5 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -1959,7 +1959,15 @@ module.exports = [ // a block, no observe-gated bytes. Brotli layout, not code: the same // source diff is -21 B on the signals floor (9,792 -> 9,771) and -11 B // on prod CSR (15,854 -> 15,843). - limit: "17.70 KB", + // One records channel (2026-09-24): 17.70 -> 17.75 KB, measured at + // 17,699 B against `next`'s 17,677 at dcca7d46e (+22 B, 1 B under the + // old cap). The channel's listener lists went from a Set copied per emit + // to copy-on-write arrays (`includes` + spread on subscribe, `filter` on + // unsubscribe, an entry deleted when empty) so `emit` allocates nothing, + // and `diagnostics.emit` hands each listener the subject as a second + // argument in place of the removed `OBSERVE.subjectOf` lookup. Observe + // only; prod scenarios byte-identical. + limit: "17.75 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -2241,6 +2249,15 @@ module.exports = [ // observer-exclusion walk, so the solid-js/refresh HMR memo is recorded // nowhere (creation, re-run, checks) while what it owns stays observed. // The tier's own +52 B is charged in the scenario above. + // One records channel (2026-09-24): no cap change, measured at 31,689 B + // against `next`'s 31,686 at dcca7d46e (+3 B; the tier's own +22 is + // charged above, so the engine is -19). Gone: the engine's own listener + // map, `emitRecord`, `recordSubject`, the `subscribe` overloads, the five + // history getters, `isSilentHold`/`isLongHold`. Arrived: `history(type)` + // over the five buffers, `HoldEvent.silent`/`.long` at settle, the + // `wantsRerun` gate at recomputeStart and the checks reading the run's + // facts instead of the record. The scenario's own consumer now + // subscribes through `OBSERVE.records`. limit: "31.70 KB", modifyEsbuildConfig: observeEsbuildConfig }, diff --git a/scripts/size/csr-app-attribution.js b/scripts/size/csr-app-attribution.js index 2f3cf14d2..3ad36e439 100644 --- a/scripts/size/csr-app-attribution.js +++ b/scripts/size/csr-app-attribution.js @@ -2,13 +2,14 @@ // consumer that actually turns attribution on ships. The delta against the // observe CSR scenario is the engine's whole cost. import { render, Show, For, Loading, Errored } from "@solidjs/web"; -import { createSignal, createMemo, lazy } from "solid-js"; +import { createSignal, createMemo, lazy, OBSERVE } from "solid-js"; import { attribution, formatRerun } from "solid-js/attribution"; -// A records consumer: enable, subscribe, format — no folds (`costs`, -// `feedback`), which are named exports a production adapter never imports. +// A records consumer: enable, subscribe on the channel, format — no folds +// (`costs`, `feedback`), which are named exports a production adapter never +// imports. attribution.enable({ log: false }); -attribution.subscribe(e => console.log(formatRerun(e))); +OBSERVE.records.subscribe("rerun", e => console.log(formatRerun(e))); const [n, setN] = createSignal(0); const Page = lazy(() => import("./lazy-page.js")); render(() => {