From 500ab012c1bb37167e4bf3d87c1baabad42b3eab Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 24 Sep 2026 21:14:06 -0700 Subject: [PATCH] =?UTF-8?q?refactor!:=20prune=20observability=20surface=20?= =?UTF-8?q?=E2=80=94=20OBSERVE.ownerPath,=20DEV.guideUrl,=20drop=20install?= =?UTF-8?q?/aliases/unused=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of the public-API consolidation. Removes OBSERVE.attribution.install and the AttributionHooks type, DEV.setConsoleFooter (now an @internal seam), the TraceSlot/OriginRef aliases, PerformanceTracksOptions.group, and the untested solid-js/refresh modes (esm, webpack5, rspack-esm) — with @solidjs/compiler's transformRefresh `bundler` restricted to match. Moves ownerPath() to OBSERVE.ownerPath and diagnosticGuideUrl() to DEV.guideUrl; why() also takes a scope name. Engine record types live on the /attribution entries only, refs on the main entries only. @solidjs/diagnostics types its tables off the runtimes' RecordEvent, adds the recovery table, and bumps the artifact format to 8. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/observability-surface-prune.md | 9 + .../plans/chrome-performance-tracks-plan.md | 8 +- documentation/plans/observe-tier-plan.md | 13 +- documentation/plans/server-dev-build-plan.md | 17 +- .../production-observability-sketch.md | 12 +- documentation/solid-2.0/08-dev-diagnostics.md | 23 +- .../__tests__/refresh-options.test.js | 10 +- .../refresh/fixtures/bundler-esm/code.jsx | 13 -- .../refresh/fixtures/bundler-esm/expected.js | 35 --- .../refresh/fixtures/bundler-esm/options.json | 1 - .../refresh/fixtures/bundler-esm/output.js | 35 --- .../fixtures/bundler-webpack5/code.jsx | 13 -- .../fixtures/bundler-webpack5/expected.js | 35 --- .../fixtures/bundler-webpack5/options.json | 1 - .../fixtures/bundler-webpack5/output.js | 35 --- packages/compiler/index.js | 7 +- packages/compiler/src/refresh/mod.rs | 12 +- packages/compiler/src/refresh/transform.rs | 20 +- packages/compiler/types.d.ts | 8 +- packages/diagnostics/README.md | 2 +- packages/diagnostics/package.json | 4 + packages/diagnostics/src/artifact.ts | 2 +- packages/diagnostics/src/assertions.ts | 4 +- packages/diagnostics/src/browser.ts | 19 +- packages/diagnostics/src/index.ts | 18 +- packages/diagnostics/src/protocol.ts | 13 +- packages/diagnostics/src/records.ts | 30 ++- packages/diagnostics/src/types.ts | 213 ++++-------------- .../diagnostics/tests/browser-bridge.test.ts | 3 +- packages/diagnostics/tests/capture.test.ts | 26 ++- .../diagnostics/tests/responsiveness.test.ts | 2 +- packages/signals/src/attribution.prod.ts | 1 - packages/signals/src/attribution.ts | 8 +- .../signals/src/core/attribution-hooks.ts | 29 +-- .../signals/src/core/attribution-queries.ts | 13 +- packages/signals/src/core/attribution.ts | 10 +- packages/signals/src/core/dev.ts | 86 +++++-- packages/signals/src/core/index.ts | 9 +- packages/signals/src/index.ts | 9 +- packages/signals/tests/attribution.test.ts | 5 + packages/signals/tests/diagnostics.test.ts | 43 +++- packages/signals/tests/dist-artifacts.test.ts | 15 +- packages/solid/src/console-footer.ts | 28 +-- packages/solid/src/index.ts | 34 +-- packages/solid/src/refresh/index.ts | 71 +++--- packages/solid/src/server/hydration.ts | 6 +- packages/solid/src/server/index.ts | 4 +- packages/solid/src/server/observe.ts | 2 +- packages/solid/src/server/signals.ts | 23 +- packages/solid/test/refresh.spec.ts | 3 +- packages/web/performance-tracks/src/index.ts | 60 ++--- packages/web/src/client.ts | 2 +- packages/web/src/index.server.ts | 2 +- packages/web/src/observe.ts | 14 +- packages/web/src/trace.ts | 5 +- packages/web/test/observe.type-tests.ts | 6 +- packages/web/test/performance-tracks.spec.tsx | 10 +- .../diagnostics-server-scenario.spec.tsx | 81 +++---- pnpm-lock.yaml | 6 + scripts/size/.size-limit.js | 19 +- turbo.json | 10 +- 61 files changed, 545 insertions(+), 712 deletions(-) create mode 100644 .changeset/observability-surface-prune.md delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-esm/code.jsx delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-esm/expected.js delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-esm/options.json delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-esm/output.js delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/code.jsx delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/expected.js delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/options.json delete mode 100644 packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/output.js diff --git a/.changeset/observability-surface-prune.md b/.changeset/observability-surface-prune.md new file mode 100644 index 000000000..53f395ed1 --- /dev/null +++ b/.changeset/observability-surface-prune.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +"@solidjs/diagnostics": patch +"@solidjs/compiler": patch +--- + +Prune the observability surface: `OBSERVE.attribution.install`, the `AttributionHooks` type, `DEV.setConsoleFooter`, the `TraceSlot` and `OriginRef` aliases, `PerformanceTracksOptions.group`, and the untested `solid-js/refresh` runtime modes (`esm`, `webpack5`, `rspack-esm`) are gone — `@solidjs/compiler`'s `transformRefresh({ bundler })` accepts only `"vite" | "standard"` to match. `ownerPath()` is now `OBSERVE.ownerPath(subject)` and `diagnosticGuideUrl()` is `DEV.guideUrl(code)`; `why()` also accepts a scope name. Engine record types (`RerunEvent`, `HoldEvent`, …) live on `solid-js/attribution` only, and `InteractionRef`/`NavigationRef` on the main entries only. `@solidjs/diagnostics` types its record tables off the runtimes' own catalogue (`RecordEvent`), adds the `recovery` table, and bumps the artifact `formatVersion` to 8. diff --git a/documentation/plans/chrome-performance-tracks-plan.md b/documentation/plans/chrome-performance-tracks-plan.md index 8b4006f37..4ed627aeb 100644 --- a/documentation/plans/chrome-performance-tracks-plan.md +++ b/documentation/plans/chrome-performance-tracks-plan.md @@ -99,8 +99,9 @@ interaction.at`). Propagation track read `signal → computed → effect`. - **Stage 4 — dev enrichments** (`@solidjs/web`, `solid-js`): diagnostics as Timings markers with `performanceIssue` at `warn`+ (`learnMoreUrl` = - `diagnosticGuideUrl(code)`, the repair guide's section, now exported from - `solid-js`; `info` stays a plain marker; under the scrub only code, kind + the repair guide's section for the code — since landed as + `DEV.guideUrl(code)` (dev tier; the link is omitted in observe builds), the + `diagnosticGuideUrl` export from `solid-js` it began as is gone; `info` stays a plain marker; under the scrub only code, kind and owner travel); `console.createTask(label)` on the dev component record (`_component.task`) and every span/marker emitted inside the nearest component's task so its stack in the panel is the JSX site; @@ -159,7 +160,8 @@ transition)`. Shape: `{ ownerPath?, at, shownMs, interaction? }` (item 4's Tracks"; the subpath stays on `@solidjs/web` because the adapter paints web records (`call`, `frame`) and inherits the dev/observe/prod tiering. - Imports only `attribution`, `formatRerun`, `formatOrigin`, the hold - verdicts, and now `diagnosticGuideUrl` — never `costs`/`feedback`, which + verdicts (and reads `OBSERVE.ownerPath` / `DEV.guideUrl` off the runtime + objects) — never `costs`/`feedback`, which would re-enable the fold tables the engine diet made optional. - `minMs` is `0` in dev and `0.05` in observe builds; the vendor adapter keeps its own thresholds (sketch §4.1). The wave span is always painted and diff --git a/documentation/plans/observe-tier-plan.md b/documentation/plans/observe-tier-plan.md index eeac58aeb..54130f555 100644 --- a/documentation/plans/observe-tier-plan.md +++ b/documentation/plans/observe-tier-plan.md @@ -62,9 +62,14 @@ byte-identical to today under every bundler. capture, emit }, attribution: { install, installed, withInteraction }, 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 }`. +getSources, getObservers, report, setConsoleFooter }`. _(As landed, then + pruned: `attribution.install` is internal to `@solidjs/signals` — only + `installed` is public, as an opaque presence check; `setConsoleFooter` is an + `@internal` seam `solid-js` imports, not a `DEV` member; `OBSERVE` also + carries `ownerPath(subject)`, and `DEV` carries `guideUrl(code)`.)_ - **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 + core's side only: the hook slot (`install(hooks)` — since made internal; + `installed` stays public — and interaction frame (`withInteraction`, which the web runtime calls on every dispatch and which is `fn()` with no engine installed). The engine — `enable/disable/history(type)/why/costs/feedback/markFlight/ @@ -209,8 +214,8 @@ _Status (2026-09-16)._ Landed, in three pieces: 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`). +…)`). `@solidjs/diagnostics` stores re-runs verbatim (as `RerunEvent` + itself — the `RerunRecord` alias it carried for a while is gone). - **Clocks: no per-record `ts`.** Every `at` the engine and the runtimes emit is on the `performance.now()` clock, consistently; a second clock per record would cost bytes on every record and drift against the first. The diff --git a/documentation/plans/server-dev-build-plan.md b/documentation/plans/server-dev-build-plan.md index db368837d..172b7ab2e 100644 --- a/documentation/plans/server-dev-build-plan.md +++ b/documentation/plans/server-dev-build-plan.md @@ -164,7 +164,8 @@ Decision: **reuse `@solidjs/signals`'s channel, do not fork it.** > **Update 2026-09-08.** `observe-tier-plan.md` PR A landed between P0 and > P1 and renames what the bullets below refer to: the channel is > `OBSERVE.diagnostics` (`OBSERVE` exists in dev and observe builds); the -> console face is `DEV.report` / `DEV.setConsoleFooter` (dev only); the server +> console face is `DEV.report` (dev only; the footer is registered through an +> internal seam `solid-js` imports, no longer a `DEV` member); the server > gates wiring on the `"_SOLID_OBSERVE_"` literal and checks on > `"_SOLID_DEV_"`; `emit` accepts an explicit `ownerPath`, so the server > labels its own owners without signals walking `SSROwner._parent`. @@ -351,12 +352,14 @@ becomes the contract test for server codes. > (`null` otherwise — client captures, the browser bridge). _(v6, with C4's > client half: `artifact.records.{boundary, invocation, frame, call}`, > folded from the core's `OBSERVE.records` on both platforms and always -> present.)_ The package still -> depends on `@solidjs/signals` alone: it reads the channel by its contract -> (`subscribe(type, listener)`, structurally) and mirrors the record -> types (`BoundaryRecord`, `InvocationRecord`, `FrameRecord`, `CallRecord`); the web server -> suite pins the mirrors to the runtime types at compile time, both ways and -> by key set. JSONL egress adds one line per record. The contract +> present.)_ The package's runtime +> imports are `@solidjs/signals` alone: it reads the channel by its contract +> (`subscribe(type, listener)`). Its record tables are typed off the +> runtimes' own catalogue (`RecordEvent` for `boundary`, `recovery`, +> `invocation`, `frame`, `call`; `solid-js` and `@solidjs/web` are type-only +> peers) — the hand-mirrored `BoundaryRecord`/`InvocationRecord`/`FrameRecord`/ +> `CallRecord` this first shipped with are gone; the web server suite pins +> the tables to the runtime types at compile time. JSONL egress adds one line per record. The contract > test is `packages/web/test/server/diagnostics-server-scenario.spec.tsx` > (harness aliased from source in `vite.config.server.mjs` and > `tsconfig.test.json`): the seeded `HEAD_TAG_INVALID` and `SERVER_WRITE` diff --git a/documentation/proposals/production-observability-sketch.md b/documentation/proposals/production-observability-sketch.md index 5fc49fc8c..54c664f5e 100644 --- a/documentation/proposals/production-observability-sketch.md +++ b/documentation/proposals/production-observability-sketch.md @@ -82,8 +82,10 @@ whether they need dev checks or ride the observe wiring. vendor-neutral (OTel-shaped where spans make sense). Observability-vendor consumption is a separate track and must not appear in this package." `attribution.ts` states the pattern: _one mechanism, N front-ends_ — a - vendor SDK is one more implementer of `AttributionHooks` / subscriber to - the event feeds, never a fork of the engine. + vendor SDK is one more subscriber to the event feeds (`OBSERVE.records`, + `OBSERVE.diagnostics`), never a fork of the engine — and not an + implementer of the engine's hook table, which is internal to + `@solidjs/signals`. --- @@ -288,7 +290,7 @@ build. Serialized as-is: since observe-tier-plan PR B the event carries `nodeId` 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 +listener's second argument, `live`), so `@solidjs/diagnostics` stores `RerunEvent` itself. Attached to the interaction span only above thresholds (4.1); otherwise folded into the span's aggregates. ### 4.5 Cause chain (from `ChangeRecord`) @@ -403,8 +405,8 @@ Solid (this repo): `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 - `@solidjs/diagnostics`; the finding/hold/interaction projections should +4. Serializable projections as exported types (`RerunEvent` is already + serializable and is what `@solidjs/diagnostics` stores; the finding/hold/interaction projections should live next to it — the plan already says protocol types publish from there). 5. An enabled-engine overhead benchmark. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index e8db67267..c084dda8f 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -15,7 +15,7 @@ Every console report is one entry built for a human to act on: - The message, with the code in brackets and the repair in the text. - An `in` line naming the owners enclosing the subject, root first — component roots as ``, computations by their `name` option or the `effect`/`computed` default (`in › › › effect`) — a compiled binding effect reads as what it writes (`span.textContent`, `div.class:active`, a hole `div.children`) when the JSX compiler's `sourceNames.bindings` is on (both `@solidjs/babel-plugin` and `@solidjs/compiler` take the same `sourceNames` option, on by default in dev builds alongside `components`); a primitive reads as the identifier it was declared as (`count`, `doubled`, `todos.title`, `createCounter.value` inside a composed primitive) when `@solidjs/compiler`'s standalone `transformSourceNames` pass has run — primitive naming is not a JSX-transform feature: the build tool applies that pass to every module, `.ts`/`.js` and JSX alike, independently of which JSX compiler handles the file (the Vite plugin's `sourceNames.primitives`, on by default in its dev and `observe` postures; solid-vite-plugin #371) — or as its `name` option otherwise. The same chain is `event.ownerPath` on the structured event. A component's name is the tag as written in source when the JSX compiler's `sourceNames.components` is on (`createComponent(Home, props, "Home")` — on by default in dev builds; the Vite plugin also enables it for the `observe` posture, so minified observe builds still read ``), otherwise the function's `.name`, which a minifier rewrites and a `lazy()` wrapper hides. Components a library invokes by value rather than by tag (a router rendering a route's `component`) carry only the function name. - For a compiled JSX binding effect (attribute, class, style, property, spread, insert), the element it writes as a second console argument — hover highlights it on the page, click jumps to it in the Elements panel. The web runtime tags binding effects with their element in dev; the core prints whatever the subject knows. -- The first report of each code ends with a footer registered by `solid-js` (`DEV.setConsoleFooter`): the installed repair skill path (`node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md`) and the same file's stable GitHub URL anchored to the code's section. Perf, graph, and responsiveness codes add a second line pointing at `attribution.enable()` from `solid-js/attribution` and the `agent-loops` skill in `@solidjs/diagnostics`. +- The first report of each code ends with a footer `solid-js` registers through an internal seam of the engine (not a public `DEV` method): the installed repair skill path (`node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md`) and the same file's stable GitHub URL anchored to the code's section — `DEV.guideUrl(code)`, the one place that URL is built, so a profiler track's Insights link (in dev) and the console footer agree. Perf, graph, and responsiveness codes add a second line pointing at `attribution.enable()` from `solid-js/attribution` and the `agent-loops` skill in `@solidjs/diagnostics`. Attribution's own output (`[why-run]` chains) prints as collapsed console groups — one headline per re-run, the cause chain and dependency delta inside. @@ -696,7 +696,7 @@ Check (`warn`, dev only; server components). A behavior position (an event handl ## Programmatic diagnostics API -In dev and observe builds, `OBSERVE.diagnostics` provides two methods for tooling (and `OBSERVE.exclude`/`isExcluded`, described under attribution, mark an observer's own subtree so neither channel reports it): +In dev and observe builds, `OBSERVE.diagnostics` provides two methods for tooling (and `OBSERVE.exclude`/`isExcluded`, described under attribution, mark an observer's own subtree so neither channel reports it; `OBSERVE.ownerPath(subject)`, below, is the labelling the channel's events carry, for a consumer that holds a live handle): ### `OBSERVE.diagnostics.subscribe(listener)` @@ -724,6 +724,14 @@ const events = capture.stop(); // events: DiagnosticEvent[] ``` +### `DEV.guideUrl(code)` + +Dev builds only. The repair guide's section for a code — the `reactivity-diagnostics` skill's stable GitHub URL, anchored to the code (`…/SKILL.md#strict_read_untracked`). One place builds it: the console footer's "learn more" line and the profiler track's Insights link (`learnMoreUrl`, in dev) both read it, so a tool that renders findings elsewhere links to the same text. Dev-tier rather than observe because it is guidance for a developer, and the URL string on a retained object would be a cost every observe build paid. + +### `OBSERVE.ownerPath(subject)` + +The owner chain of a live owner or node, root first, as the events carry it (`["", "", "effect"]` — `event.ownerPath` on a finding, the `ownerPath` field on a record); `undefined` for `null`/`undefined` and for a subject with no named owner above it. For a consumer holding the live handle the channel passed beside an event — the profiler track labelling a span by the computation it received — rather than a copy that already left the process, which carries the path itself. + Each `DiagnosticEvent` has: | Field | Type | Description | @@ -925,7 +933,7 @@ Beyond the always-on diagnostics above, dev and observe builds ship an opt-in ** ← signal "notifications" write (#5) 2 → 3 ``` -The engine is its own entry, `solid-js/attribution` (re-exporting `@solidjs/signals/attribution`), so a build that never imports it never ships it: the runtime carries only the hook slot the engine installs into (`OBSERVE.attribution.install`) and the two declared frames — the interaction frame the web runtime opens around event dispatch (`OBSERVE.attribution.withInteraction`) and the origin frame a router opens around its navigation write (`OBSERVE.attribution.withOrigin`). The import is legal in every tier — the prod tier resolves an inert engine with the same surface, so app code needs no per-tier guard. +The engine is its own entry, `solid-js/attribution` (re-exporting `@solidjs/signals/attribution`), so a build that never imports it never ships it: the runtime carries only the hook slot the engine installs into (internal to `@solidjs/signals`; `OBSERVE.attribution.installed` says whether an engine is present) and the two declared frames — the interaction frame the web runtime opens around event dispatch (`OBSERVE.attribution.withInteraction`) and the origin frame a router opens around its navigation write (`OBSERVE.attribution.withOrigin`). The import is legal in every tier — the prod tier resolves an inert engine with the same surface, so app code needs no per-tier guard. ### API (`solid-js/attribution`) @@ -1019,9 +1027,9 @@ OBSERVE // undefined — and puts it on its record (the web runtime's "call" record // does this at dispatch); an observer then joins the two by identity. const origin = OBSERVE.attribution.currentOrigin(); -// An external engine (devtools) installs into the same slot the built-in -// one uses: OBSERVE.attribution.install(hooks) / .installed. The installed -// hooks are also registered on globalThis under +// Whether an engine is present: OBSERVE.attribution.installed (the hook +// table itself is opaque — the slot is the engine's, not a public seam). +// The installed hooks are also registered on globalThis under // Symbol.for("@solidjs/signals/observe/attribution"), the records channel's // reach for a layer bundled without a framework import. // An observer that renders inside the app it watches (an APM adapter's @@ -1115,7 +1123,6 @@ const disable = enablePerformanceTracks({ minMs: 0, // floor for run spans; 0 in dev, 0.05 in observe builds rich: true, // performance.measure with tooltips/properties (dev default) vs console.timeStamp scrub: false, // drop value previews and element text (observe default) - group: "Solid", // the track group attribution: {} // options for the engine hold it takes (log: false by default) }); ``` @@ -1132,6 +1139,6 @@ Findings become markers: every `DiagnosticEvent` delivered while enabled is a ma The engine is decoupled from the core through a narrow dev-only hook surface (`attribution-hooks.ts`): the core's only obligation is to report true facts (recompute start/end with lane and transition posture, committed writes, async landings, refreshes) at the moments they happen. All semantics — stamps, cause chains, timings, thresholds — live in the engine. Disabled cost is one null check per hook site; production builds fold every site out entirely (the size guard enforces byte-parity). -The same hook surface is the intended substrate for external devtools: install your own `AttributionHooks` implementation instead of the built-in engine — one mechanism, two front-ends. +External devtools build on the engine's public face — `attribution.enable()` plus `OBSERVE.records` — not on the hook table, which is internal to `@solidjs/signals` (a devtools engine that replaced the built-in one would be a change to the package, not an integration). Naming: attribution output uses debug names from the `name` option on primitives (`createSignal(0, { name: "count" })`); store nodes are named `store.path` automatically while the engine is active. Unnamed nodes fall back to their owner id. diff --git a/packages/compiler/__tests__/refresh-options.test.js b/packages/compiler/__tests__/refresh-options.test.js index e058037b1..1d3f67522 100644 --- a/packages/compiler/__tests__/refresh-options.test.js +++ b/packages/compiler/__tests__/refresh-options.test.js @@ -28,8 +28,16 @@ describe("transformRefresh options", () => { it("rejects unsupported bundlers", () => { expect(() => transformRefresh(CODE, { ...OPTIONS, bundler: "webpack" })).toThrow( - /`bundler` option must be "esm", "vite", "webpack5", "rspack-esm" or "standard"/ + /`bundler` option must be "vite" or "standard"/ ); + // The modes `solid-js/refresh` dropped: the runtime probes `module.hot` + // / `import.meta.webpackHot` itself under `standard`, and Snowpack's + // `esm` shape is Vite's. + for (const bundler of ["esm", "webpack5", "rspack-esm"]) { + expect(() => transformRefresh(CODE, { ...OPTIONS, bundler })).toThrow( + /`bundler` option must be "vite" or "standard"/ + ); + } }); it("rejects the unported JSX-granularity mode", () => { diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/code.jsx b/packages/compiler/__tests__/refresh/fixtures/bundler-esm/code.jsx deleted file mode 100644 index f59dc03f1..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/code.jsx +++ /dev/null @@ -1,13 +0,0 @@ -export function A() { - return

a

; -} -export function B() { - return

b

; -} -function C() { - return

c

; -} -export default function D() { - return

d

; -} -export { C }; diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/expected.js b/packages/compiler/__tests__/refresh/fixtures/bundler-esm/expected.js deleted file mode 100644 index 224e8798f..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/expected.js +++ /dev/null @@ -1,35 +0,0 @@ -import { $$component as _$$component } from "solid-refresh"; -import { $$refresh as _$$refresh } from "solid-refresh"; -import { $$registry as _$$registry } from "solid-refresh"; -const _REGISTRY = _$$registry(); -const A = _$$component(_REGISTRY, "A", function A() { - return

a

; -}, { - location: "src/bundler-esm.jsx:1:7", - signature: "9ed2f96b" -}); -const D = _$$component(_REGISTRY, "D", function D() { - return

d

; -}, { - location: "src/bundler-esm.jsx:10:15", - signature: "113af5ae" -}); -const C = _$$component(_REGISTRY, "C", function C() { - return

c

; -}, { - location: "src/bundler-esm.jsx:7:0", - signature: "9f974c0b" -}); -const B = _$$component(_REGISTRY, "B", function B() { - return

b

; -}, { - location: "src/bundler-esm.jsx:4:7", - signature: "f41aa0df" -}); -export { A }; -export { B }; -export default D; -export { C }; -if (import.meta.hot) { - _$$refresh("esm", import.meta.hot, _REGISTRY); -} diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/options.json b/packages/compiler/__tests__/refresh/fixtures/bundler-esm/options.json deleted file mode 100644 index 45920aa4a..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/options.json +++ /dev/null @@ -1 +0,0 @@ -{ "bundler": "esm" } diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/output.js b/packages/compiler/__tests__/refresh/fixtures/bundler-esm/output.js deleted file mode 100644 index 514fc36fc..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-esm/output.js +++ /dev/null @@ -1,35 +0,0 @@ -import { $$component as _$$component } from "solid-refresh"; -import { $$refresh as _$$refresh } from "solid-refresh"; -import { $$registry as _$$registry } from "solid-refresh"; -const _REGISTRY = _$$registry(); -const A = _$$component(_REGISTRY, "A", function A() { - return

a

; -}, { - location: "src/bundler-esm.jsx:1:7", - signature: "9ed2f96b" -}); -const D = _$$component(_REGISTRY, "D", function D() { - return

d

; -}, { - location: "src/bundler-esm.jsx:10:15", - signature: "113af5ae" -}); -const C = _$$component(_REGISTRY, "C", function C() { - return

c

; -}, { - location: "src/bundler-esm.jsx:7:0", - signature: "9f974c0b" -}); -const B = _$$component(_REGISTRY, "B", function B() { - return

b

; -}, { - location: "src/bundler-esm.jsx:4:7", - signature: "f41aa0df" -}); -export { A }; -export { B }; -export default D; -export { C }; -if (import.meta.hot) { - _$$refresh("esm", import.meta.hot, _REGISTRY); -} diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/code.jsx b/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/code.jsx deleted file mode 100644 index f59dc03f1..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/code.jsx +++ /dev/null @@ -1,13 +0,0 @@ -export function A() { - return

a

; -} -export function B() { - return

b

; -} -function C() { - return

c

; -} -export default function D() { - return

d

; -} -export { C }; diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/expected.js b/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/expected.js deleted file mode 100644 index 0a20baaca..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/expected.js +++ /dev/null @@ -1,35 +0,0 @@ -import { $$component as _$$component } from "solid-refresh"; -import { $$refresh as _$$refresh } from "solid-refresh"; -import { $$registry as _$$registry } from "solid-refresh"; -const _REGISTRY = _$$registry(); -const A = _$$component(_REGISTRY, "A", function A() { - return

a

; -}, { - location: "src/bundler-webpack5.jsx:1:7", - signature: "9ed2f96b" -}); -const D = _$$component(_REGISTRY, "D", function D() { - return

d

; -}, { - location: "src/bundler-webpack5.jsx:10:15", - signature: "113af5ae" -}); -const C = _$$component(_REGISTRY, "C", function C() { - return

c

; -}, { - location: "src/bundler-webpack5.jsx:7:0", - signature: "9f974c0b" -}); -const B = _$$component(_REGISTRY, "B", function B() { - return

b

; -}, { - location: "src/bundler-webpack5.jsx:4:7", - signature: "f41aa0df" -}); -export { A }; -export { B }; -export default D; -export { C }; -if (import.meta.webpackHot) { - _$$refresh("webpack5", import.meta.webpackHot, _REGISTRY); -} diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/options.json b/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/options.json deleted file mode 100644 index f5dcb41af..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/options.json +++ /dev/null @@ -1 +0,0 @@ -{ "bundler": "webpack5" } diff --git a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/output.js b/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/output.js deleted file mode 100644 index 8ccbf08ba..000000000 --- a/packages/compiler/__tests__/refresh/fixtures/bundler-webpack5/output.js +++ /dev/null @@ -1,35 +0,0 @@ -import { $$component as _$$component } from "solid-refresh"; -import { $$refresh as _$$refresh } from "solid-refresh"; -import { $$registry as _$$registry } from "solid-refresh"; -const _REGISTRY = _$$registry(); -const A = _$$component(_REGISTRY, "A", function A() { - return

a

; -}, { - location: "src/bundler-webpack5.jsx:1:7", - signature: "9ed2f96b" -}); -const D = _$$component(_REGISTRY, "D", function D() { - return

d

; -}, { - location: "src/bundler-webpack5.jsx:10:15", - signature: "113af5ae" -}); -const C = _$$component(_REGISTRY, "C", function C() { - return

c

; -}, { - location: "src/bundler-webpack5.jsx:7:0", - signature: "9f974c0b" -}); -const B = _$$component(_REGISTRY, "B", function B() { - return

b

; -}, { - location: "src/bundler-webpack5.jsx:4:7", - signature: "f41aa0df" -}); -export { A }; -export { B }; -export default D; -export { C }; -if (import.meta.webpackHot) { - _$$refresh("webpack5", import.meta.webpackHot, _REGISTRY); -} diff --git a/packages/compiler/index.js b/packages/compiler/index.js index 7fae37b03..28516f805 100644 --- a/packages/compiler/index.js +++ b/packages/compiler/index.js @@ -194,7 +194,8 @@ const refreshOptionKeys = new Set([ "sourceMap" ]); -const refreshBundlers = new Set(["esm", "vite", "webpack5", "rspack-esm", "standard"]); +// The runtime modes `solid-js/refresh` knows (its `RuntimeType`). +const refreshBundlers = new Set(["vite", "standard"]); function validateRefreshOptions(options) { if (options == null) return options; @@ -217,9 +218,7 @@ function validateRefreshOptions(options) { throw new TypeError(`@solidjs/compiler \`${key}\` option must be boolean`); } if (key === "bundler" && !refreshBundlers.has(value)) { - throw new TypeError( - '@solidjs/compiler `bundler` option must be "esm", "vite", "webpack5", "rspack-esm" or "standard"' - ); + throw new TypeError('@solidjs/compiler `bundler` option must be "vite" or "standard"'); } if (key === "jsx") { // The Babel plugin's JSX-granularity mode (its default!) is not diff --git a/packages/compiler/src/refresh/mod.rs b/packages/compiler/src/refresh/mod.rs index 8a12665a6..fcdc2fe4c 100644 --- a/packages/compiler/src/refresh/mod.rs +++ b/packages/compiler/src/refresh/mod.rs @@ -36,9 +36,10 @@ pub struct TransformRefreshOptions { /// Used for `location` metadata (cwd-relative, like the Babel plugin) /// and to pick the parser dialect. Without it no locations are emitted. pub filename: Option, - /// `"esm" | "vite" | "webpack5" | "rspack-esm" | "standard"` (default - /// `"standard"`), selecting the `import.meta.hot` / - /// `import.meta.webpackHot` / `module.hot` API. + /// `"vite" | "standard"` (default `"standard"`) — the runtime modes + /// `solid-js/refresh` knows: `import.meta.hot`, or the `module.hot` / + /// `import.meta.webpackHot` shape (webpack, Rspack) the runtime probes + /// itself. pub bundler: Option, /// Wrap top-level `render()`/`hydrate()` calls (imported from /// `@solidjs/web`) with `hot.dispose` cleanup. Default `true`. @@ -65,13 +66,10 @@ pub fn transform_refresh( let bundler = match options.bundler.as_deref() { None | Some("standard") => Bundler::Standard, - Some("esm") => Bundler::Esm, Some("vite") => Bundler::Vite, - Some("webpack5") => Bundler::Webpack5, - Some("rspack-esm") => Bundler::RspackEsm, Some(other) => { return Err(Error::from_reason(format!( - "transformRefresh `bundler` option must be \"esm\", \"vite\", \"webpack5\", \"rspack-esm\" or \"standard\", got {other:?}" + "transformRefresh `bundler` option must be \"vite\" or \"standard\", got {other:?}" ))); } }; diff --git a/packages/compiler/src/refresh/transform.rs b/packages/compiler/src/refresh/transform.rs index 9e8d55b14..4c36345a0 100644 --- a/packages/compiler/src/refresh/transform.rs +++ b/packages/compiler/src/refresh/transform.rs @@ -38,22 +38,20 @@ use super::signature::{CommentInfo, Printer}; const SPAN: Span = Span::new(0, 0); +/// The runtime modes `solid-js/refresh` knows (`RuntimeType`): Vite's +/// `import.meta.hot`, and the `module.hot` / `import.meta.webpackHot` +/// shape every other bundler exposes (`standard`). The emitted string is +/// what the runtime's `$$refresh`/`$$decline` switch on. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Bundler { - Esm, Vite, - Webpack5, - RspackEsm, Standard, } impl Bundler { fn as_str(self) -> &'static str { match self { - Bundler::Esm => "esm", Bundler::Vite => "vite", - Bundler::Webpack5 => "webpack5", - Bundler::RspackEsm => "rspack-esm", Bundler::Standard => "standard", } } @@ -1259,20 +1257,16 @@ impl<'a> RefreshTransform<'a> { // --- HMR blocks ----------------------------------------------------------- - /// `import.meta.hot` / `import.meta.webpackHot` / `module.hot`. + /// `import.meta.hot` (vite) / `module.hot` (standard). fn hot_expression(&self) -> Expression<'a> { let ast = self.ast(); match self.config.bundler { - Bundler::Esm | Bundler::Vite | Bundler::Webpack5 | Bundler::RspackEsm => { - let property = match self.config.bundler { - Bundler::Webpack5 | Bundler::RspackEsm => "webpackHot", - _ => "hot", - }; + Bundler::Vite => { let meta = ast.expression_import_meta(SPAN); Expression::StaticMemberExpression(ast.alloc_static_member_expression( SPAN, meta, - ast.identifier_name(SPAN, property), + ast.identifier_name(SPAN, "hot"), false, )) } diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index 88ba6e033..f0428ceb1 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -219,12 +219,12 @@ export interface TransformRefreshOptions { */ filename?: string; /** - * Selects the HMR API: `import.meta.hot` (esm/vite), - * `import.meta.webpackHot` (webpack5/rspack-esm) or `module.hot` - * (standard). + * Selects the HMR API — the runtime modes `solid-js/refresh` knows: + * `import.meta.hot` (vite), or `module.hot` / `import.meta.webpackHot` + * (standard: webpack, Rspack — the runtime probes for whichever exists). * @default "standard" */ - bundler?: "esm" | "vite" | "webpack5" | "rspack-esm" | "standard"; + bundler?: "vite" | "standard"; /** * Wrap top-level `render()`/`hydrate()` calls (imported from * `@solidjs/web`) with `hot.dispose` cleanup. diff --git a/packages/diagnostics/README.md b/packages/diagnostics/README.md index e920adb69..a94c6d7ad 100644 --- a/packages/diagnostics/README.md +++ b/packages/diagnostics/README.md @@ -59,7 +59,7 @@ artifact.records.frame; // every frame stream produced (side: "server"): id, she `boundary` is one row per `` boundary that **waited** (a boundary that rendered on its first pass has nothing to attribute): how long it held its content up (`durationMs`), how long finished content then sat behind `` siblings (`heldMs`), how many render passes it took (`passes` — `2` is one round of async, more is a sequential chain) and how it ended (`outcome`: settled, the `renderToString` fallback, a client-only handoff, an error). `invocation` is one row per server-function execution; a direct call made during a boundary's pass carries that boundary's `id` in `boundary`, so a boundary's wait reads as the calls it consisted of. `frame` is one row per frame stream a server component rendered to (`renderServerComponent`, or a server-function response through `frameTransformResult`): time to the shell (`shellMs`) and to `complete` (`durationMs`), how it ended, and a census of what the stream carried (`fragments`, `slots`, `regions`, `errors`); a server-function response that is a frame stream has an invocation row and a frame row with the same `id`. The runtime derives two dev checks from the same facts — `SSR_BOUNDARY_WATERFALL` for a sequential chain of waits and `SSR_CLIENT_CONTENT_MASKED` for client-only content that surfaced only after a wait — so `expectNoDiagnostics` catches them without reading the tables. -In the browser (the in-process capture in a jsdom test, or the bridge under Playwright) the same tables hold the page's **requests**: `artifact.records.call` is one row per server-function call the client made — `id`, `method`, `durationMs` (the whole wait the caller saw), `outcome`, `status` — and `artifact.records.frame` rows with `side: "client"` are the frame streams it applied (`address` is the local boundary the stream was remapped onto; `outcome` adds `truncated` for a body that ended early). A `call` and the server's `invocation` of the same `id` differ by the wire; a `frame` seen from both sides joins by `id` and `version`. The tables are always present; one is empty when nothing of its kind happened. JSONL egress adds one line per record, `type` naming its table. +In the browser (the in-process capture in a jsdom test, or the bridge under Playwright) the same tables hold the page's **requests**: `artifact.records.call` is one row per server-function call the client made — `id`, `method`, `durationMs` (the whole wait the caller saw), `outcome`, `status` — and `artifact.records.frame` rows with `side: "client"` are the frame streams it applied (`address` is the local boundary the stream was remapped onto; `outcome` adds `truncated` for a body that ended early). A `call` and the server's `invocation` of the same `id` differ by the wire; a `frame` seen from both sides joins by `id` and `version`. `artifact.records.recovery` is the client's side of a server hand-off: one row per `` boundary that rendered its children as fresh client DOM because the server could not produce the fragment (`outcome: "client"` on the server's `boundary` row of the same `id`) — `waitedMs` is the fallback the person looked at while the server was still trying, `renderMs` the fresh render. The tables are always present; one is empty when nothing of its kind happened. Their row types are the runtimes' own — `BoundaryEvent`/`RecoveryEvent` from `solid-js`, `InvocationEvent`/`FrameEvent`/`CallEvent` from `@solidjs/web` — which is why those two packages are (type-only) peers of this one. JSONL egress adds one line per record, `type` naming its table. ## Assertions and budgets diff --git a/packages/diagnostics/package.json b/packages/diagnostics/package.json index 1485f8035..e5a3ac985 100644 --- a/packages/diagnostics/package.json +++ b/packages/diagnostics/package.json @@ -55,6 +55,8 @@ "@solidjs/signals": "^2.0.0-rc.9" }, "peerDependencies": { + "@solidjs/web": "^2.0.0-rc.9", + "solid-js": "^2.0.0-rc.9", "vitest": ">=2.0.0" }, "peerDependenciesMeta": { @@ -63,7 +65,9 @@ } }, "devDependencies": { + "@solidjs/web": "workspace:*", "@types/node": "^25.0.8", + "solid-js": "workspace:*", "rimraf": "^5.0.1", "typescript": "^6.0.3", "vite": "^7.0.0", diff --git a/packages/diagnostics/src/artifact.ts b/packages/diagnostics/src/artifact.ts index 82889ede8..a150fd7d2 100644 --- a/packages/diagnostics/src/artifact.ts +++ b/packages/diagnostics/src/artifact.ts @@ -1,7 +1,7 @@ import { RECORD_TYPES } from "./records.js"; import type { DiagnosticsArtifact } from "./types.js"; -export const ARTIFACT_FORMAT_VERSION = 7 as const; +export const ARTIFACT_FORMAT_VERSION = 8 as const; /** Pretty JSON for humans and for checked-in golden files. */ export function serializeArtifact(artifact: DiagnosticsArtifact): string { diff --git a/packages/diagnostics/src/assertions.ts b/packages/diagnostics/src/assertions.ts index f585dc7d4..dcc876c58 100644 --- a/packages/diagnostics/src/assertions.ts +++ b/packages/diagnostics/src/assertions.ts @@ -4,7 +4,7 @@ import type { DiagnosticCode, DiagnosticsArtifact, HoldEvent, - RerunRecord + RerunEvent } from "./types.js"; /** @@ -81,7 +81,7 @@ function requireAttributionData( return artifact.attribution; } -function requireAttribution(artifact: DiagnosticsArtifact, caller: string): RerunRecord[] { +function requireAttribution(artifact: DiagnosticsArtifact, caller: string): RerunEvent[] { return requireAttributionData(artifact, caller).reruns; } diff --git a/packages/diagnostics/src/browser.ts b/packages/diagnostics/src/browser.ts index 7f2580b7d..70c2bd54d 100644 --- a/packages/diagnostics/src/browser.ts +++ b/packages/diagnostics/src/browser.ts @@ -9,15 +9,15 @@ * vite plugin inject it) and call `installDiagnosticsBridge()`. */ import { OBSERVE, flush } from "@solidjs/signals"; -import { attribution as engine, costs, feedback } from "@solidjs/signals/attribution"; +import { attribution as engine, costs, feedback, why } from "@solidjs/signals/attribution"; import { captureRecords, type RecordsCapture } from "./records.js"; import type { - AttributionCosts, - AttributionFeedback, + AttributionCostTables, + AttributionFeedbackTables, AttributionOptions, DiagnosticsArtifact, HoldEvent, - RerunRecord + RerunEvent } from "./types.js"; export const BRIDGE_GLOBAL = "__SOLID_DIAGNOSTICS__"; @@ -41,14 +41,14 @@ export interface DiagnosticsBridge { begin(options?: BridgeBeginOptions): void; end(): BridgePayload; active(): boolean; - /** Re-runs of one scope (by name) recorded by the open session. */ - whyDidRun(name: string): RerunRecord[]; + /** Re-runs of one scope (by name) recorded by the open session — the engine's `why(name)`. */ + whyDidRun(name: string): RerunEvent[]; /** Cost tables of the open session so far, without closing it. */ - costs(): AttributionCosts; + costs(): AttributionCostTables; /** Transition holds the open session has recorded so far. */ holds(): HoldEvent[]; /** Feedback tables (what the user waited on) of the open session so far. */ - feedback(): AttributionFeedback; + feedback(): AttributionFeedbackTables; } /** @@ -150,7 +150,8 @@ export function installDiagnosticsBridge( }, whyDidRun(name) { requireAttributionSession("whyDidRun"); - return toSerializable(engine.history("rerun").filter(event => event.nodeName === name)); + // The engine's own query, by name: an out-of-process driver holds no node. + return toSerializable(why(name)); }, costs() { requireAttributionSession("costs"); diff --git a/packages/diagnostics/src/index.ts b/packages/diagnostics/src/index.ts index 8804414e2..71778b20a 100644 --- a/packages/diagnostics/src/index.ts +++ b/packages/diagnostics/src/index.ts @@ -19,15 +19,20 @@ export type { SilentHoldOptions, HoldBudgetOptions } from "./assertions.js"; +// The artifact's own shapes, and the runtimes' types it is built from, by +// their own names: the engine's from `@solidjs/signals/attribution`, the +// findings' from `@solidjs/signals`. The record tables (`ArtifactRecords`) +// are typed straight off the channel's catalogue — `BoundaryEvent` is +// `solid-js`'s, `InvocationEvent`/`CallEvent`/`FrameEvent` are +// `@solidjs/web`'s — so those are imported from their packages, not here. export type { Attribution, AttributionOptions, - AttributionCosts, - AttributionFeedback, + AttributionCostTables, + AttributionFeedbackTables, ArtifactAttribution, ArtifactRecords, - BoundaryRecord, - CallRecord, + ArtifactRecordType, ChangeOrigin, ChangeRecord, DiagnosticsArtifact, @@ -38,13 +43,8 @@ export type { FeedbackInteraction, FeedbackSource, FlightStats, - FrameAppliedRecord, - FrameProducedRecord, - FrameRecord, HoldEvent, - InvocationRecord, RerunEvent, - RerunRecord, ScopeCost, WriteCost } from "./types.js"; diff --git a/packages/diagnostics/src/protocol.ts b/packages/diagnostics/src/protocol.ts index 46a1aaab9..fc316cb8c 100644 --- a/packages/diagnostics/src/protocol.ts +++ b/packages/diagnostics/src/protocol.ts @@ -8,7 +8,12 @@ * release. Shape changes here are contract changes. */ import type { BridgeBeginOptions, BridgePayload } from "./browser.js"; -import type { AttributionCosts, AttributionFeedback, HoldEvent, RerunRecord } from "./types.js"; +import type { + AttributionCostTables, + AttributionFeedbackTables, + HoldEvent, + RerunEvent +} from "./types.js"; /** Vite custom-event names carrying requests into the page and back. */ export const DIAGNOSTICS_REQUEST_EVENT = "solid:diagnostics:request"; @@ -25,13 +30,13 @@ export interface DiagnosticsMethods { /** Whether a session is currently open. */ active: { params: undefined; result: boolean }; /** Re-runs of one scope (by name) recorded by the open session. */ - whyDidRun: { params: { name: string }; result: RerunRecord[] }; + whyDidRun: { params: { name: string }; result: RerunEvent[] }; /** Cost tables of the open session so far, without closing it. */ - costs: { params: undefined; result: AttributionCosts }; + costs: { params: undefined; result: AttributionCostTables }; /** Transition holds recorded by the open session so far. */ holds: { params: undefined; result: HoldEvent[] }; /** Feedback tables (what the user waited on) of the open session so far. */ - feedback: { params: undefined; result: AttributionFeedback }; + feedback: { params: undefined; result: AttributionFeedbackTables }; } export type DiagnosticsMethod = keyof DiagnosticsMethods; diff --git a/packages/diagnostics/src/records.ts b/packages/diagnostics/src/records.ts index dd2130613..b56f8da3b 100644 --- a/packages/diagnostics/src/records.ts +++ b/packages/diagnostics/src/records.ts @@ -4,17 +4,17 @@ * same subscriptions. */ import { OBSERVE } from "@solidjs/signals"; -import type { ArtifactRecords } from "./types.js"; +import type { RecordEvent } from "solid-js"; +import type { ArtifactRecordType, ArtifactRecords } from "./types.js"; /** The record types this format knows; `artifact.records` has one table per entry. */ -export const RECORD_TYPES = ["boundary", "invocation", "frame", "call"] as const; - -// The channel, read structurally: its record types are declared by -// `solid-js` and `@solidjs/web`, which this package does not depend on — -// from here the catalogue is empty, and `subscribe`'s type parameter with it. -interface RecordsChannel { - subscribe(type: string, listener: (event: unknown) => void): () => void; -} +export const RECORD_TYPES = [ + "boundary", + "recovery", + "invocation", + "frame", + "call" +] as const satisfies readonly ArtifactRecordType[]; export interface RecordsCapture { stop(): ArtifactRecords; @@ -27,10 +27,16 @@ export interface RecordsCapture { * other listener — and each table keeps delivery order. */ export function captureRecords(): RecordsCapture { - const channel = OBSERVE!.records as unknown as RecordsChannel; - const tables: ArtifactRecords = { boundary: [], invocation: [], frame: [], call: [] }; + const channel = OBSERVE!.records; + const tables: ArtifactRecords = { + boundary: [], + recovery: [], + invocation: [], + frame: [], + call: [] + }; const unsubscribe = RECORD_TYPES.map(type => - channel.subscribe(type, event => { + channel.subscribe(type, (event: RecordEvent) => { (tables[type] as object[]).push({ ...(event as object) }); }) ); diff --git a/packages/diagnostics/src/types.ts b/packages/diagnostics/src/types.ts index 030ade07e..1caf043cb 100644 --- a/packages/diagnostics/src/types.ts +++ b/packages/diagnostics/src/types.ts @@ -1,21 +1,26 @@ -import type { DiagnosticEvent } from "@solidjs/signals"; +import type { DiagnosticEvent, RecordEvent } from "solid-js"; +// Type-only: `@solidjs/web` declares the `invocation`, `call` and `frame` +// records onto the channel's catalogue (`HostRecordTypes`, through +// `solid-js`); importing its types is what puts that declaration in this +// program, so `RecordEvent<"call">` resolves. Nothing of the web runtime is +// loaded — the harness runs on `@solidjs/signals` alone. +import type {} from "@solidjs/web"; import type { AttributionCostTables, AttributionFeedbackTables, - ChangeOrigin, HoldEvent, RerunEvent } from "@solidjs/signals/attribution"; /** - * The engine's record types, re-exported from `@solidjs/signals/attribution` - * by name where it exports them and derived structurally where it does not, - * so the harness stays type-locked to the engine: if a shape changes, these - * break at compile time here rather than silently at runtime. + * The engine's types, by the names the engine exports them under — the + * artifact stores records verbatim, so its vocabulary is the runtimes'. */ export type { Attribution, AttributionOptions, + AttributionCostTables, + AttributionFeedbackTables, RerunEvent, ScopeCost, WriteCost, @@ -28,181 +33,53 @@ export type { FlightStats, FallbackStats } from "@solidjs/signals/attribution"; -/** The fold tables as `costs()` / `feedback()` return them — named exports of the engine's entry. */ -export type AttributionCosts = AttributionCostTables; -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 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. - */ -export type RerunRecord = RerunEvent; export interface ArtifactAttribution { - reruns: RerunRecord[]; - costs: AttributionCosts; + /** + * Every re-run, as the engine emitted it: it names its scope by `nodeId` + * and never carries the live node (in-process consumers get it as `live` + * beside the record on `OBSERVE.records`), so the artifact copies records + * verbatim. + */ + reruns: RerunEvent[]; + /** The fold tables as `costs()` returns them. */ + costs: AttributionCostTables; /** Every hold that staged a root write — what the user waited on. */ holds: HoldEvent[]; /** The ranked feedback tables folded from `holds` and the re-runs' interactions. */ - feedback: AttributionFeedback; -} - -/** - * A `` boundary that waited during a server render — `solid-js`'s - * `"boundary"` record (`BoundaryEvent`), as delivered on `OBSERVE.records`. - * Mirrored here rather than imported: this package depends on - * `@solidjs/signals` alone, and the record types are the runtimes' - * (`solid-js`, `@solidjs/web`). The web server suite pins each mirror to - * its original at compile time. - */ -export interface BoundaryRecord { - /** The boundary's hydration id — pairs with `SSR_*` findings and with `InvocationRecord.boundary`. */ - id: string; - /** `performance.now()` when the boundary was discovered (its first pass began). */ - at: number; - /** Discovery → settle, in milliseconds: how long the boundary held up its content. */ - durationMs: number; - /** Settle → reveal: how long finished content waited behind `` siblings; 0 when nothing coordinated it. */ - heldMs: number; - /** Render passes over the content: discovery plus one per wait. `passes - 1` sequential flights. */ - passes: number; - /** - * `settled` — content rendered; `fallback` — `renderToString` shipped the - * fallback; `client` — content handed off to the client (`ssrSource: - * "client"`); `error` — the content threw and the error boundary took it. - */ - outcome: "settled" | "fallback" | "client" | "error"; - /** Whether the outcome went out as a streamed fragment (`true`) or inline with the shell. */ - streamed: boolean; - /** The `` group that coordinated the swap, when one did. */ - revealGroup?: string; - /** Root-first component labels down to the boundary, when the runtime knows them. */ - ownerPath?: string[]; + feedback: AttributionFeedbackTables; } /** - * One server function execution, on the server — `@solidjs/web`'s - * `"invocation"` record (`InvocationEvent`), mirrored on the same terms as - * `BoundaryRecord`. + * The record types the artifact tables — the runtimes' records on + * `OBSERVE.records`, by the runtime that declares each: `solid-js`'s + * `boundary` (a `` that waited during a server render) and + * `recovery` (the client's re-render of a boundary the server handed + * over); `@solidjs/web`'s `invocation` (a server-function execution), + * `call` (a server-function call from the browser) and `frame` (a frame + * stream produced or applied). The engine's own records (`rerun`, `hold`, + * …) live under `attribution`, not here. */ -export interface InvocationRecord { - /** The function id. */ - id: string; - /** `true` for an in-process call during SSR, `false` for HTTP dispatch. */ - direct: boolean; - /** `performance.now()` when the execution started. */ - at: number; - /** Start → settle, in milliseconds. */ - durationMs: number; - outcome: "ok" | "error"; - /** The settled value is a body the caller drives; `durationMs` covers the call, not the consumption. */ - deferred?: true; - /** Direct calls: the `` boundary whose render pass made the call — a `BoundaryRecord.id`. */ - boundary?: string; -} - -/** - * One server-function call made from the browser — `@solidjs/web`'s - * `"call"` record (`CallEvent`), mirrored on the same terms as - * `BoundaryRecord`. The client twin of `InvocationRecord`: the two join by - * `id`, and the difference between their durations is the wire. - */ -export interface CallRecord { - /** The function id — the same `id` the server's `InvocationRecord` carries. */ - id: string; - /** `performance.now()` when the call was made. */ - at: number; - /** Call → settle, in milliseconds: request, response and decode, as the caller awaited it. */ - durationMs: number; - /** `GET` for a GET-encoded read, `POST` otherwise. */ - method: "GET" | "POST"; - outcome: "ok" | "error"; - /** The response's HTTP status, once one arrived; absent when the fetch itself failed. */ - status?: number; - /** - * What the call ran for, when the attribution engine knew — the - * interaction, navigation, effect or action frame: the engine's own origin - * object, so it is `attribution.holds[].interaction` / `holds[].origin` - * by identity within one capture (equal by value once serialized). Absent - * without an engine or outside any frame. - */ - origin?: ChangeOrigin; - /** The settled value is a body the caller drives; `durationMs` covers the call, not the consumption. */ - deferred?: true; -} - -/** - * One frame stream — produced on the server (`renderServerComponent`, or a - * server-function response through `frameTransformResult`) or applied on - * the client (`applyFrameResponse`) — `@solidjs/web`'s `"frame"` record - * (`FrameEvent`), mirrored on the same terms as `BoundaryRecord`. `side` - * says which end observed it; the two halves join by `id` and `version`. - */ -export type FrameRecord = FrameProducedRecord | FrameAppliedRecord; - -interface FrameRecordBase { - /** The frame id on the wire: the server function's id for a server-function response (the `"invocation"`/`"call"` records' `id`); `""` for a bare stream. */ - id: string; - /** The stream version: as the producer stamped it (server), or as the consumer restamped it (client). */ - version: number; - /** `performance.now()` at the stream's `start` chunk. */ - at: number; - /** Start → `complete`, in milliseconds: the whole stream, fragments included. */ - durationMs: number; - /** Start → the shell (`html`) chunk, in milliseconds: time to first content. Absent when the stream carried no shell. */ - shellMs?: number; - /** Transport chunks between `start` and `complete`, all types. */ - chunks: number; - /** `fragment` chunks: `` content that settled after the shell. */ - fragments: number; - /** `slot` chunks: render-prop invocations the client fills. */ - slots: number; - /** Nested server-content regions: `html` chunks addressed to a child frame id. */ - regions: number; - /** `error` chunks: fragments that failed, plus a synchronous failure. */ - errors: number; -} - -/** The server half of a `FrameRecord`: one frame stream produced. */ -export interface FrameProducedRecord extends FrameRecordBase { - side: "server"; - /** `complete` — the render ran to the end; `error` — it threw synchronously and the stream carried only the failure. */ - outcome: "complete" | "error"; -} - -/** The client half of a `FrameRecord`: one frame stream applied. */ -export interface FrameAppliedRecord extends FrameRecordBase { - side: "client"; - /** The local id the chunks were applied under, when the consumer remapped the wire id (`applyFrameResponse`'s `as`). */ - address?: string; - /** `complete` — the `complete` chunk arrived; `truncated` — the body ended before it; `error` — the read failed. */ - outcome: "complete" | "truncated" | "error"; -} +export type ArtifactRecordType = "boundary" | "recovery" | "invocation" | "frame" | "call"; /** * What the runtimes recorded on `OBSERVE.records` during the scenario, one - * table per record type, each in delivery (settle) order. On the server the - * evidence is waits and calls (`boundary`, `invocation`, the frame's server - * half); in the browser it is the calls made and the streams applied - * (`call`, the frame's client half). Joins: `invocation.boundary` → + * table per record type, each in delivery (settle) order and typed as the + * runtime that emits it types the record (`RecordEvent` — `BoundaryEvent` + * from `solid-js`, `InvocationEvent`/`CallEvent`/`FrameEvent` from + * `@solidjs/web`), so a shape change there is a shape change here with no + * mirror to keep in step. On the server the evidence is waits and calls + * (`boundary`, `invocation`, the frame's server half); in the browser it is + * the calls made, the streams applied and the boundaries recovered (`call`, + * the frame's client half, `recovery`). Joins: `invocation.boundary` → * `boundary.id` reads a boundary's wait as the calls it consisted of; * `call.id` = `invocation.id` and `frame.id` across `side`s pair the two - * ends of one request. + * ends of one request; `recovery.id` = `boundary.id` pairs a handed-over + * boundary with its client render. */ -export interface ArtifactRecords { - /** Every `` boundary that waited, in settle (or reveal) order. */ - boundary: BoundaryRecord[]; - /** Every server function execution, in settle order. */ - invocation: InvocationRecord[]; - /** Every frame stream produced or applied, in completion order. */ - frame: FrameRecord[]; - /** Every server-function call made from the browser, in settle order. */ - call: CallRecord[]; -} +export type ArtifactRecords = { + [K in ArtifactRecordType]: RecordEvent[]; +}; /** * The unit of exchange between a captured run and everything downstream: @@ -218,9 +95,11 @@ export interface ArtifactRecords { * both platforms, plus the client's `call` and the frame's client half. v7 * adds `timeOrigin`, the anchor that turns every relative `at` into absolute * time, and stores re-runs as the engine emits them (`nodeId`, no `node`). + * v8 adds the `recovery` table (the client's `"recovery"` record) and + * types every table as the emitting runtime types the record. */ export interface DiagnosticsArtifact { - formatVersion: 7; + formatVersion: 8; /** Human/agent-readable label for the captured scenario. */ scenario?: string; capturedAt: string; diff --git a/packages/diagnostics/tests/browser-bridge.test.ts b/packages/diagnostics/tests/browser-bridge.test.ts index b8384272b..a767bf2e1 100644 --- a/packages/diagnostics/tests/browser-bridge.test.ts +++ b/packages/diagnostics/tests/browser-bridge.test.ts @@ -82,7 +82,7 @@ describe("browser bridge + playwright adapter", () => { ); app.dispose(); - expect(artifact.formatVersion).toBe(7); + expect(artifact.formatVersion).toBe(8); expect(artifact.scenario).toBe("browser-toggle"); expect(artifact.timeOrigin).toBe(performance.timeOrigin); expectNoDiagnostics(artifact); @@ -98,6 +98,7 @@ describe("browser bridge + playwright adapter", () => { // The records crossed too, tables intact, the handles left in the page. expect(artifact.records).toEqual({ boundary: [], + recovery: [], invocation: [], frame: [frame], call: [call] diff --git a/packages/diagnostics/tests/capture.test.ts b/packages/diagnostics/tests/capture.test.ts index 897ab6420..33f175eb3 100644 --- a/packages/diagnostics/tests/capture.test.ts +++ b/packages/diagnostics/tests/capture.test.ts @@ -31,7 +31,7 @@ describe("captureArtifact — diagnostics channel", () => { { scenario: "orphan effect", attribution: deterministicAttribution } ); - expect(artifact.formatVersion).toBe(7); + expect(artifact.formatVersion).toBe(8); expect(artifact.scenario).toBe("orphan effect"); // The anchor for every relative `at` in the artifact. expect(artifact.timeOrigin).toBe(performance.timeOrigin); @@ -188,7 +188,13 @@ describe("captureArtifact — records tables", () => { it("is present, with empty tables, when nothing was recorded", async () => { const { artifact } = await captureArtifact(() => {}, { attribution: false }); - expect(artifact.records).toEqual({ boundary: [], invocation: [], frame: [], call: [] }); + expect(artifact.records).toEqual({ + boundary: [], + recovery: [], + invocation: [], + frame: [], + call: [] + }); }); it("collects every record type in delivery order, copied, and ends its subscriptions", async () => { @@ -233,6 +239,7 @@ describe("captureArtifact — records tables", () => { outcome: "ok", status: 200 }; + const recovery = { id: "0-0-1", at: 120, waitedMs: 110, renderMs: 3 }; expect(channel.observed("boundary")).toBe(false); const { artifact } = await captureArtifact( () => { @@ -242,12 +249,14 @@ describe("captureArtifact — records tables", () => { channel.emit("frame", produced, {}); channel.emit("call", call, {}); channel.emit("frame", applied, {}); + channel.emit("recovery", recovery, {}); channel.emit("hydration", { id: "unknown-type" }, {}); }, { scenario: "records", attribution: false } ); expect(artifact.records).toEqual({ boundary: [boundary], + recovery: [recovery], invocation: [invocation], frame: [produced, applied], call: [call] @@ -256,7 +265,7 @@ describe("captureArtifact — records tables", () => { expect(artifact.records.boundary[0]).not.toBe(boundary); expect(artifact.records.invocation[0]!.boundary).toBe(artifact.records.boundary[0]!.id); // The subscriptions end with the capture. - for (const type of ["boundary", "invocation", "frame", "call"]) { + for (const type of ["boundary", "recovery", "invocation", "frame", "call"]) { expect(channel.observed(type), type).toBe(false); } @@ -266,10 +275,11 @@ describe("captureArtifact — records tables", () => { .map(line => JSON.parse(line)); expect(lines[0]).toMatchObject({ type: "meta", - recordCounts: { boundary: 1, invocation: 1, frame: 2, call: 1 } + recordCounts: { boundary: 1, recovery: 1, invocation: 1, frame: 2, call: 1 } }); expect(lines.slice(1)).toEqual([ { type: "boundary", ...boundary }, + { type: "recovery", ...recovery }, { type: "invocation", ...invocation }, { type: "frame", ...produced }, { type: "frame", ...applied }, @@ -280,6 +290,12 @@ describe("captureArtifact — records tables", () => { it("reports zero counts in the JSONL header when nothing was recorded", async () => { const { artifact } = await captureArtifact(() => {}, { attribution: false }); const meta = JSON.parse(artifactToJSONL(artifact).split("\n")[0]!); - expect(meta.recordCounts).toEqual({ boundary: 0, invocation: 0, frame: 0, call: 0 }); + expect(meta.recordCounts).toEqual({ + boundary: 0, + recovery: 0, + invocation: 0, + frame: 0, + call: 0 + }); }); }); diff --git a/packages/diagnostics/tests/responsiveness.test.ts b/packages/diagnostics/tests/responsiveness.test.ts index 1185b2d7d..eae14b490 100644 --- a/packages/diagnostics/tests/responsiveness.test.ts +++ b/packages/diagnostics/tests/responsiveness.test.ts @@ -107,7 +107,7 @@ async function capturePageTurn(withBusyIndicator: boolean, holdFor = 20) { describe("artifact — responsiveness evidence", () => { it("carries the holds and the feedback tables", async () => { const artifact = await capturePageTurn(false); - expect(artifact.formatVersion).toBe(7); + expect(artifact.formatVersion).toBe(8); const { holds, feedback } = artifact.attribution!; expect(holds).toHaveLength(1); expect(holds[0]).toMatchObject({ diff --git a/packages/signals/src/attribution.prod.ts b/packages/signals/src/attribution.prod.ts index 5aa88f608..1f413b403 100644 --- a/packages/signals/src/attribution.prod.ts +++ b/packages/signals/src/attribution.prod.ts @@ -78,4 +78,3 @@ export type { FeedbackSource, FlightStats } from "./core/attribution-feedback.js"; -export type { InteractionRef, NavigationRef, OriginRef } from "./core/attribution-hooks.js"; diff --git a/packages/signals/src/attribution.ts b/packages/signals/src/attribution.ts index 00c06c7c4..139e8acc8 100644 --- a/packages/signals/src/attribution.ts +++ b/packages/signals/src/attribution.ts @@ -2,8 +2,8 @@ * `@solidjs/signals/attribution` — the "why did this run" engine. * * A separate entry on purpose: the core ships only the hook slot - * (`OBSERVE.attribution.install`) and the interaction frame - * (`OBSERVE.attribution.withInteraction`); the engine that turns hook facts + * (`OBSERVE.attribution`, which `enable()` installs into) and the declared + * frames (`withInteraction`, `withOrigin`); the engine that turns hook facts * into re-run explanations, cost tables, holds and feedback lives here, so an * observe build carries it only when something imports this module. The dev * and observe tiers resolve to this file; the prod tier resolves to @@ -52,4 +52,6 @@ export type { FeedbackSource, FlightStats } from "./core/attribution-feedback.js"; -export type { InteractionRef, NavigationRef, OriginRef } from "./core/attribution-hooks.js"; +// The frame refs (`InteractionRef`, `NavigationRef`) are the core's — they +// describe what `OBSERVE.attribution.withInteraction`/`withOrigin` take — +// and are exported from the main entry beside those, not from here. diff --git a/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 79b7164b6..15e881d49 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -13,6 +13,11 @@ import type { Computed, Owner, Signal } from "./types.js"; * unless an engine is installed, so the disabled cost is one null check per * site, and prod builds fold every site out behind __OBSERVE__. * + * The contract is internal: the built-in engine installs through + * `setAttributionHooks`, and nothing public names this interface + * (`OBSERVE.attribution.installed` exposes the installed table as an opaque + * object). It becomes public surface again the day a second engine exists. + * * IMPORTANT for implementers of call sites: a hook call must never sit inside * a `try` block — rollup's tryCatchDeoptimization retains functions referenced * inside `try` even behind a folded __OBSERVE__ guard, which re-couples the @@ -40,7 +45,7 @@ export interface AttributionHooks { * navigates — or stands alone (a redirect from an action, a programmatic * `navigate()`). Frames nest strictly, so the engine keeps a stack. */ - originStart(ref: OriginRef): void; + originStart(ref: NavigationRef): void; originEnd(): void; /** * A `flush()` drain is starting: work is scheduled or a transition is @@ -240,10 +245,14 @@ export interface InteractionRef { /** * A navigation, as a router describes it to `withOrigin` around the location - * write it is about to perform. Match eagerly and describe before writing: - * the engine keys the work the write causes — the hold behind route data, - * the re-runs, the verdicts — to this record, and names it by the - * parametrized route so occurrences fold together. + * write it is about to perform — what `withOrigin` accepts: a declared unit + * of work whose writes the engine attributes as a whole, discriminated by + * `kind` so another kind (a form submission, a tab switch) can join without + * the seam changing shape; the engine knows `navigation` today. Match + * eagerly and describe before writing: the engine keys the work the write + * causes — the hold behind route data, the re-runs, the verdicts — to this + * record, and names it by the parametrized route so occurrences fold + * together. * * The engine keeps the object and reads `name`, `to` and `params` again when * the navigation settles (and when a hold on it is judged), so a router whose @@ -279,14 +288,6 @@ export interface NavigationRef { redirect?: number; } -/** - * What `withOrigin` accepts: a declared unit of work whose writes the engine - * should attribute as a whole. A discriminated union so kinds can be added - * (a form submission, a tab switch) without the seam changing shape; the - * engine knows `navigation` today. - */ -export type OriginRef = NavigationRef; - export let attrHooks: AttributionHooks | null = null; /** @@ -349,7 +350,7 @@ export function withInteraction(ref: InteractionRef, fn: () => T): T { * : setLocation(to); * ``` */ -export function withOrigin(ref: OriginRef, fn: () => T): T { +export function withOrigin(ref: NavigationRef, fn: () => T): T { const hooks = attrHooks; if (hooks === null) return fn(); hooks.originStart(ref); diff --git a/packages/signals/src/core/attribution-queries.ts b/packages/signals/src/core/attribution-queries.ts index 37d543ccd..fbac2937d 100644 --- a/packages/signals/src/core/attribution-queries.ts +++ b/packages/signals/src/core/attribution-queries.ts @@ -13,14 +13,19 @@ function nodeOf(target: unknown): Computed { } /** - * 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. + * Re-run history for one scope — pass a memo/effect accessor or raw node, + * or a scope's name as a string (an out-of-process consumer such as the + * diagnostics bridge holds no node, only the `nodeName` the records + * carry). Records name their scope by `nodeId`; a node that has never run + * under the engine has none, and no history. By name, every scope of that + * name answers. */ export function why(target: unknown): RerunEvent[] { + const history = attribution.history("rerun"); + if (typeof target === "string") return history.filter(event => event.nodeName === target); const id = nodeIdOf(nodeOf(target)); if (id === undefined) return []; - return attribution.history("rerun").filter(event => event.nodeId === id); + return history.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 b164af074..a694c8a34 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -2,7 +2,7 @@ import { setAttributionHooks, type AttributionHooks, type InteractionRef, - type OriginRef + type NavigationRef } from "./attribution-hooks.js"; import { CONFIG_DERIVED_OVERRIDE, CONFIG_PLUMBING, NOT_PENDING } from "./constants.js"; import { @@ -934,7 +934,7 @@ function popFrame(kind: "effect" | "action" | "navigation") { * interaction is long gone but whose frame remembers it — else the ambient * one (a link click's handler). */ -function originStart(ref: OriginRef): void { +function originStart(ref: NavigationRef): void { // A redirect hop re-enters the pending navigation's frame — the same object, // so its writes stamp the same origin and replace the pending write without // superseding it (see "Navigations"). @@ -3290,7 +3290,7 @@ export interface NavigationEvent { interface NavState { event: NavigationEvent; /** The router's description — re-read at settle (see `syncNavigation`). A redirect replaces it. */ - ref: OriginRef; + ref: NavigationRef; /** Frames on the stack for this navigation: the opener's, plus a nested redirect hop's. */ open: number; /** A flush parked its writes in a transition (`holdStart`). */ @@ -3328,7 +3328,7 @@ function syncNavigation(state: NavState): void { } else frame.params = event.params = ref.params; } -function openNavigation(frame: ChangeOrigin, ref: OriginRef): void { +function openNavigation(frame: ChangeOrigin, ref: NavigationRef): void { const event: NavigationEvent = { at: frame.at!, writes: 0, origin: frame }; if (frame.from !== undefined) event.from = frame.from; if (frame.interaction !== undefined) event.interaction = frame.interaction; @@ -3355,7 +3355,7 @@ function lastOpenNavigation(): NavState | undefined { } /** A redirect re-describes `state`: the current destination becomes a hop it abandoned. */ -function redirectNavigation(state: NavState, ref: OriginRef): void { +function redirectNavigation(state: NavState, ref: NavigationRef): void { const event = state.event; // As the router last described the destination being left behind. syncNavigation(state); diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 9e685e581..0bee7b270 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -1,12 +1,10 @@ import { attrHooks, currentOrigin, - setAttributionHooks, withInteraction, withOrigin, - type AttributionHooks, type InteractionRef, - type OriginRef + type NavigationRef } from "./attribution-hooks.js"; import type { ChangeOrigin, @@ -187,21 +185,21 @@ export interface Diagnostics { } /** - * The core's side of attribution: the hook slot an engine installs into, and + * The core's side of attribution: the slot the engine installs into, and * the interaction frame the rendering runtime opens around event dispatch. * The engine itself — "why did this run", costs, holds, feedback — is * `@solidjs/signals/attribution`, a separate entry so an observe build pays - * for it only when something imports it. + * for it only when something imports it; enabling it is what installs. */ export interface AttributionSlot { /** - * Installs `hooks` as the engine the core reports facts to (`null` - * uninstalls). One engine at a time; the built-in engine's `enable()` calls - * this, and an external consumer (devtools) may install its own instead. + * The installed engine, or `null` when none is enabled — the one fact a + * runtime reads off the slot (`solid-js` opens a `console.createTask` per + * component only while an engine is there to attribute to it). The + * object is the engine's hook table, opaque here: the hook contract is + * between the core and its engine, not public surface. */ - install(hooks: AttributionHooks | null): void; - /** The installed engine's hooks, or `null` when none is installed. */ - readonly installed: AttributionHooks | null; + readonly installed: object | null; /** * Run `fn` as a user interaction's handler: root writes inside stamp it as * their origin, and actions/effects/flights it causes carry it. The web @@ -217,7 +215,7 @@ export interface AttributionSlot { * router calls this around its location write; nothing else is * router-specific. `fn()` when no engine is installed. */ - withOrigin(ref: OriginRef, fn: () => T): T; + withOrigin(ref: NavigationRef, fn: () => T): T; /** * The provenance a root write performed now would be stamped with — the * interaction whose handler is running, the navigation or effect or action @@ -392,6 +390,19 @@ export interface Observe { exclude(owner: Owner): void; /** Whether `subject` sits under an excluded owner (itself included). */ isExcluded(subject: DiagnosticSubject | null | undefined): boolean; + /** + * Root-first names of the owners enclosing `subject` (inclusive when the + * subject is itself a named owner) — component roots as ``, + * computations by their `name` option — the labels every finding and + * record carries as `ownerPath` (`["", "", "label"]`), from + * the one walk that stamps them, so a consumer locating a live node it + * was handed (`live`, a diagnostic's `subject`) reads the same path. + * Signals hop to their registering owner; unnamed owners are skipped; + * `undefined` when nothing on the chain is named. Names exist only in the + * observing tiers, which is why the walk lives here and not on the prod + * surface. + */ + ownerPath(subject: DiagnosticSubject | null | undefined): string[] | undefined; } /** @@ -408,15 +419,14 @@ export interface Dev { /** Console face of an emitted event — see `reportDiagnostic`. */ report(entry: DiagnosticEvent): void; /** - * Registers a console footer appended to the first console report of - * each diagnostic code — a discovery pointer to deeper guidance (e.g. - * solid-js registers its shipped repair skill). Reported events carry - * it as trailing lines of the same console entry; events that surface as - * a thrown error instead get it as a follow-up line. Returning undefined - * for an event suppresses the footer. Passing undefined unregisters and - * resets the once-per-code memory. + * The stable URL of `code`'s section in the repair guide — the + * `reactivity-diagnostics` skill shipped with `solid-js`, one section per + * code. The one place the URL is built: the console footer prints it and + * the performance tracks' Insights link (`learnMoreUrl`) reads it, so both + * name the same section. Dev-tier: it is guidance for a developer, and a + * URL string on a retained object is a cost every observe build would pay. */ - setConsoleFooter(footer: ((event: DiagnosticEvent) => string | undefined) | undefined): void; + guideUrl(code: DiagnosticCode): string; } // A dev build without the wiring is a build whose checks emit into a channel @@ -432,6 +442,26 @@ let diagnosticSequence = 0; let consoleFooter: ((event: DiagnosticEvent) => string | undefined) | undefined; const footeredCodes = new Set(); +/** + * Registers the console footer appended to the first console report of + * each diagnostic code — a discovery pointer to deeper guidance. Reported + * events carry it as trailing lines of the same console entry; events that + * surface as a thrown error instead get it as a follow-up line. Returning + * undefined for an event suppresses the footer. Passing undefined + * unregisters and resets the once-per-code memory. + * + * @internal A seam for `solid-js`, which owns the repair skill the footer + * names and installs it from both of its entries; not part of `DEV`. No-op + * outside dev builds, where nothing reports to the console. + */ +export function setConsoleFooter( + footer: ((event: DiagnosticEvent) => string | undefined) | undefined +): void { + if (!__DEV__) return; + consoleFooter = footer; + footeredCodes.clear(); +} + const diagnostics: Diagnostics = { subscribe(listener) { diagnosticListeners.add(listener); @@ -459,7 +489,6 @@ const diagnostics: Diagnostics = { }; const attributionSlot: AttributionSlot = { - install: setAttributionHooks, get installed() { return attrHooks; }, @@ -536,7 +565,8 @@ export const OBSERVE: Observe = __OBSERVE__ excludedOwners.add(owner); hasExclusions = true; }, - isExcluded + isExcluded, + ownerPath } : (undefined as unknown as Observe); @@ -569,6 +599,12 @@ export function isSuppressed(entry: DiagnosticEvent): boolean { return suppressedEvents.has(entry); } +// The repair guide: the `reactivity-diagnostics` skill `solid-js` ships, +// one section per code, at its stable GitHub path. GitHub heading anchors +// are lowercased with underscores kept (`### SILENT_HOLD` → `#silent_hold`). +const GUIDE_URL = + "https://github.com/solidjs/solid/blob/main/packages/solid/skills/reactivity-diagnostics/SKILL.md"; + export const DEV: Dev = __DEV__ ? { hooks, @@ -578,9 +614,8 @@ export const DEV: Dev = __DEV__ getSources, getObservers, report: reportDiagnostic, - setConsoleFooter(footer) { - consoleFooter = footer; - footeredCodes.clear(); + guideUrl(code) { + return `${GUIDE_URL}#${code.toLowerCase()}`; } } : (undefined as unknown as Dev); @@ -614,6 +649,7 @@ export type DiagnosticSubject = Owner | Signal | Computed; * subject is itself a named owner). Signals hop to their registering owner * (`_owner`, set by registerGraph). Unnamed owners are skipped so the path * reads as the component tree plus the scope: ` › › effect`. + * Public as `OBSERVE.ownerPath`; the core's own sites import it directly. */ export function ownerPath(subject: DiagnosticSubject | null | undefined): string[] | undefined { if (!subject) return undefined; diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index 945ca1de8..c9849835a 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -66,12 +66,7 @@ export { type IQueue, type QueueCallback } from "./scheduler.js"; -export type { - AttributionHooks, - InteractionRef, - NavigationRef, - OriginRef -} from "./attribution-hooks.js"; +export type { InteractionRef, NavigationRef } from "./attribution-hooks.js"; export { ROOT_ERROR_HOOK } from "./scheduler.js"; export { configureClientErrors, @@ -82,7 +77,7 @@ export { export { DEV, OBSERVE, - ownerPath, + setConsoleFooter, type AttributionSlot, type Dev, type Observe, diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 215949e1a..cb028bea6 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -29,10 +29,15 @@ export { enforceLoadingBoundary, enableExternalSource, resetErrorHalt, - ownerPath, configureClientErrors, ROOT_ERROR_HOOK } from "./core/index.js"; +/** + * @internal The dev console footer seam — registered by `solid-js`, which + * owns the repair skill the footer points at. Not part of `DEV`; a no-op + * outside dev builds. + */ +export { setConsoleFooter } from "./core/index.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction @@ -51,14 +56,12 @@ export type { ExternalSource, ExternalSourceConfig, Refreshable, - AttributionHooks, AttributionSlot, ClientErrorContext, ClientErrorHook, ClientErrorsConfig, InteractionRef, NavigationRef, - OriginRef, Dev, Observe, ServerObserve, diff --git a/packages/signals/tests/attribution.test.ts b/packages/signals/tests/attribution.test.ts index 6f5dcd8a5..867ca3b57 100644 --- a/packages/signals/tests/attribution.test.ts +++ b/packages/signals/tests/attribution.test.ts @@ -307,6 +307,11 @@ describe("why-did-this-run attribution", () => { expect(why(node)).toEqual(doubles); // A copy that left the process names nothing the engine can look up. expect(why(JSON.parse(JSON.stringify(doubles[0])))).toEqual([]); + // By name, for a caller that holds no node (an out-of-process driver + // through the diagnostics bridge): the `nodeName` filter over history. + expect(why("double")).toEqual(doubles); + expect(why("reader")).toEqual(readers); + expect(why("nobody")).toEqual([]); }); it("warns on hot scopes, once per window", () => { diff --git a/packages/signals/tests/diagnostics.test.ts b/packages/signals/tests/diagnostics.test.ts index d9fe1b308..47d6aa4e0 100644 --- a/packages/signals/tests/diagnostics.test.ts +++ b/packages/signals/tests/diagnostics.test.ts @@ -17,7 +17,7 @@ import { DEV, OBSERVE } from "../src/index.js"; -import { emitDiagnostic, ownerPath, reportDiagnostic } from "../src/core/dev.js"; +import { emitDiagnostic, ownerPath, reportDiagnostic, setConsoleFooter } from "../src/core/dev.js"; // Several diagnostics are escaping errors, which halt the reactive system. afterEach(() => { @@ -274,8 +274,16 @@ describe("diagnostics", () => { }); describe("diagnostics console footer", () => { + it("DEV.guideUrl is the repair guide's section for a code", () => { + const url = DEV!.guideUrl("STRICT_READ_UNTRACKED"); + expect(url).toMatch( + /^https:\/\/github\.com\/solidjs\/solid\/blob\/main\/.*SKILL\.md#strict_read_untracked$/ + ); + expect(DEV!.guideUrl("WIDE_WRITE")).toBe(url.replace(/#.*$/, "#wide_write")); + }); + afterEach(() => { - DEV!.setConsoleFooter(undefined); + setConsoleFooter(undefined); }); const warnTexts = (warn: { mock: { calls: unknown[][] } }) => @@ -283,7 +291,7 @@ describe("diagnostics console footer", () => { it("folds the footer into the first reported console entry of each code — one entry per finding", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - DEV!.setConsoleFooter(event => `footer:${event.code}`); + setConsoleFooter(event => `footer:${event.code}`); reportDiagnostic( emitDiagnostic({ @@ -317,7 +325,7 @@ describe("diagnostics console footer", () => { it("defers the footer to a follow-up line only for thrown (unreported) errors", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - DEV!.setConsoleFooter(event => `footer:${event.code}`); + setConsoleFooter(event => `footer:${event.code}`); // A throw site: emits, then throws the message — never reports. emitDiagnostic({ @@ -336,7 +344,7 @@ describe("diagnostics console footer", () => { it("does not double-print when a reported error's microtask runs after the report", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {}); - DEV!.setConsoleFooter(event => `footer:${event.code}`); + setConsoleFooter(event => `footer:${event.code}`); reportDiagnostic( emitDiagnostic({ @@ -356,7 +364,7 @@ describe("diagnostics console footer", () => { it("suppresses the footer when the callback returns undefined", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - DEV!.setConsoleFooter(() => undefined); + setConsoleFooter(() => undefined); reportDiagnostic( emitDiagnostic({ @@ -373,7 +381,7 @@ describe("diagnostics console footer", () => { it("re-registering resets the once-per-code memory", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - DEV!.setConsoleFooter(() => "footer:first"); + setConsoleFooter(() => "footer:first"); reportDiagnostic( emitDiagnostic({ code: "STRICT_READ_UNTRACKED", @@ -382,7 +390,7 @@ describe("diagnostics console footer", () => { message: "one" }) ); - DEV!.setConsoleFooter(() => "footer:second"); + setConsoleFooter(() => "footer:second"); reportDiagnostic( emitDiagnostic({ code: "STRICT_READ_UNTRACKED", @@ -445,6 +453,25 @@ describe("diagnostics owner path", () => { expect(ownerPath(null)).toBeUndefined(); }); + it("is public as OBSERVE.ownerPath, the same labelling the events carry", () => { + let inner: any; + createRoot(() => { + nameOwner(""); + createEffect( + () => { + inner = getOwner(); + return 1; + }, + () => {}, + { name: "sync" } + ); + }); + flush(); + expect(OBSERVE!.ownerPath).toBe(ownerPath); + expect(OBSERVE!.ownerPath(inner)).toEqual(["", "sync"]); + expect(OBSERVE!.ownerPath(null)).toBeUndefined(); + }); + it("defaults the subject to the ambient context and omits the path when there is none", () => { let inside: ReturnType | undefined; createRoot(() => { diff --git a/packages/signals/tests/dist-artifacts.test.ts b/packages/signals/tests/dist-artifacts.test.ts index 7640f7f80..9792b886b 100644 --- a/packages/signals/tests/dist-artifacts.test.ts +++ b/packages/signals/tests/dist-artifacts.test.ts @@ -42,14 +42,20 @@ function expectObserveLive(mod: Tier) { expect(typeof observe.records.observed).toBe("function"); expect(typeof observe.records.emit).toBe("function"); expect((globalThis as any)[Symbol.for("@solidjs/signals/observe/records")]).toBe(observe.records); - // The core's side of attribution is the slot and the two declared frames only. - expect(typeof observe.attribution.install).toBe("function"); + // The core's side of attribution is the slot's `installed` and the two + // declared frames only: the engine installs itself (no public `install`). + expect(observe.attribution.install).toBeUndefined(); expect(typeof observe.attribution.withInteraction).toBe("function"); expect(typeof observe.attribution.withOrigin).toBe("function"); expect(observe.attribution.installed).toBeNull(); expect(observe.attribution.enable).toBeUndefined(); // The live subject rides beside each record and diagnostic; no lookup. expect(observe.subjectOf).toBeUndefined(); + // The owner walk is an observe-tier helper on the object, not a named + // export of the prod surface; the guide URL is dev guidance (`DEV.guideUrl`) + // and its string is not on the observe object. + expect(typeof observe.ownerPath).toBe("function"); + expect(observe.diagnostics.guideUrl).toBeUndefined(); } /** @@ -140,7 +146,10 @@ function expectDevLive(mod: Tier) { expect(typeof dev.hooks).toBe("object"); expect(typeof dev.getChildren).toBe("function"); expect(typeof dev.report).toBe("function"); - expect(typeof dev.setConsoleFooter).toBe("function"); + expect(typeof dev.guideUrl).toBe("function"); + // The console footer is solid-js's seam (`setConsoleFooter`, an `@internal` + // named export), not a member of the public `DEV` object. + expect(dev.setConsoleFooter).toBeUndefined(); // The console face and devtools surface do not leak onto the observe object. expect((mod.OBSERVE as any).setConsoleFooter).toBeUndefined(); expect((mod.OBSERVE as any).hooks).toBeUndefined(); diff --git a/packages/solid/src/console-footer.ts b/packages/solid/src/console-footer.ts index 2f2049fd9..87a0b1dfc 100644 --- a/packages/solid/src/console-footer.ts +++ b/packages/solid/src/console-footer.ts @@ -14,31 +14,27 @@ // Both pointers are given twice: the installed file (what an agent working in // the repo can open with no network, at exactly the installed version) and a // stable URL (what a human in a browser console can click; Chrome linkifies -// it) — the anchor jumps to the code's own section. +// it) — the anchor jumps to the code's own section. The URL is the core's +// (`DEV.guideUrl`), so the footer and an observer's link (the performance +// tracks' Insights entry) name the same place. // // Installed by both entries — the client's and the server's — so a server // render's console report (a `SERVER_WRITE`, a `HEAD_TAG_INVALID`) carries the // same pointer as a client one. The skill has a section per code on either side. -import type { Dev, DiagnosticCode } from "@solidjs/signals"; +// The registration is the core's `setConsoleFooter` seam, `@internal` to +// this package: signals cannot know this package's skill path, and nothing +// else has a footer to register. +import { DEV, setConsoleFooter } from "@solidjs/signals"; const SKILLS_URL = "https://github.com/solidjs/solid/blob/main/packages"; -/** - * The stable URL of a diagnostic code's section in the repair guide (the - * `reactivity-diagnostics` skill shipped with `solid-js`) — what the console - * footer prints, and what an observer attaches to a finding it renders - * elsewhere (the performance tracks' `learnMoreUrl`). - */ -export function diagnosticGuideUrl(code: DiagnosticCode): string { - // GitHub heading anchors: lowercased, underscores kept (`### SILENT_HOLD` → `#silent_hold`). - return `${SKILLS_URL}/solid/skills/reactivity-diagnostics/SKILL.md#${code.toLowerCase()}`; -} - -export function installConsoleFooter(dev: Dev): void { - dev.setConsoleFooter(event => { +/** Dev-only; the callers gate on their build's dev literal, where `DEV` is defined. */ +export function installConsoleFooter(): void { + const guideUrl = DEV!.guideUrl; + setConsoleFooter(event => { const base = `[${event.code}] repair guide: node_modules/solid-js/skills/reactivity-diagnostics/SKILL.md ` + - `— ${diagnosticGuideUrl(event.code)}`; + `— ${guideUrl(event.code)}`; return event.kind === "perf" || event.kind === "graph" || event.kind === "responsiveness" ? base + `\n[${event.code}] deeper evidence: import { attribution } from "solid-js/attribution"; ` + diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 5f22b0e96..a85baade6 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -36,8 +36,7 @@ export { enforceLoadingBoundary, snapshot, untrack, - configureClientErrors, - ownerPath + configureClientErrors } from "@solidjs/signals"; /** @internal — the key a root owner carries `render`'s `onError` under, for the web runtime. */ export { ROOT_ERROR_HOOK } from "@solidjs/signals"; @@ -175,14 +174,16 @@ export function getProjectionTrace( import { IS_DEV, IS_OBSERVE } from "./client/core.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "@solidjs/signals"; import { installConsoleFooter } from "./console-footer.js"; -export { diagnosticGuideUrl } from "./console-footer.js"; export const OBSERVE: Observe | undefined = IS_OBSERVE ? _OBSERVE : undefined; export const DEV: Dev | undefined = IS_DEV ? _DEV : undefined; // The types a runtime, router or observability adapter names when it talks to // the tiers: the refs it hands `withInteraction`/`withOrigin`, the channel's -// event, and the records the attribution engine delivers. Here so the code -// that reaches for `OBSERVE.attribution.withOrigin` finds `NavigationRef` -// beside it; the engine's full surface stays on `solid-js/attribution`. +// types and the findings'. Here so the code that reaches for +// `OBSERVE.attribution.withOrigin` finds `NavigationRef` beside it, and a +// `records.subscribe` listener types from this import alone. The engine's +// records (`RerunEvent`, `HoldEvent`, …) are `solid-js/attribution`'s, with +// the engine; `ChangeOrigin` is the one of them a slot method returns +// (`currentOrigin`), so it is here too. export type { Dev, Observe, @@ -194,11 +195,9 @@ export type { RecordEvent, RecordLive, RecordListener, - AttributionHooks, AttributionSlot, InteractionRef, NavigationRef, - OriginRef, Diagnostics, DiagnosticCapture, DiagnosticCode, @@ -223,22 +222,7 @@ export type { ServerTrace } from "./server/observe.js"; export type { RecoveryEvent, RecoveryLive, RecoveryListener } from "./recovery.js"; -export type { - Acknowledgement, - ChangeOrigin, - ChangeRecord, - CreateEvent, - EffectRunEvent, - FallbackEvent, - FlightEvent, - FlushEvent, - HeldWrite, - HoldEvent, - InteractionEvent, - NavigationEvent, - NavigationHop, - RerunEvent -} from "@solidjs/signals/attribution"; +export type { ChangeOrigin } from "@solidjs/signals/attribution"; // handle multiple instance check declare global { @@ -256,7 +240,7 @@ if (IS_DEV && globalThis) { // Point-of-pain discovery: the first console report of each diagnostic code // gains a footer naming the repair skill shipped with this package — see // console-footer.ts (shared with the server entry). -if (IS_DEV && _DEV) installConsoleFooter(_DEV); +if (IS_DEV) installConsoleFooter(); /* Not Implemented export { diff --git a/packages/solid/src/refresh/index.ts b/packages/solid/src/refresh/index.ts index 694484464..eb01d1461 100644 --- a/packages/solid/src/refresh/index.ts +++ b/packages/solid/src/refresh/index.ts @@ -17,8 +17,18 @@ * The `hot.data` protocol is likewise frozen: the first evaluation of a module * stores its registry under `hot.data["solid-refresh"]`, and every evaluation * stores its own registry under `hot.data["solid-refresh-prev"]`; the accept - * callback patches the former from the latter. `"vite"` is the fully supported - * bundler mode; the others are carried over from `solid-refresh` verbatim. + * callback patches the former from the latter. + * + * Two runtime types, one per hot-API shape (`RuntimeType`): `"vite"` for the + * `import.meta.hot` API (Vite and the bundlers that implement its HMR API — + * `accept(cb)`, `invalidate()`, `data`), and `"standard"` for the + * `module.hot` / `import.meta.webpackHot` API (`accept()`, `dispose(cb)`, + * optional `invalidate`/`decline`). The `solid-refresh` package this runtime + * descends from also named `"esm"` (Snowpack's `import.meta.hot`, which + * differs from Vite's only in honouring `decline()`), `"webpack5"` and + * `"rspack-esm"` (`import.meta.webpackHot`, a strict subset of what + * `"standard"` handles); those were carried over untested and are gone — a + * compiled wrapper targeting this runtime passes one of the two shapes. * * In production builds every entry point degrades to an inert stub: * `$$component` returns the component unwrapped and `$$refresh`/`$$decline` @@ -322,15 +332,17 @@ type HotData = { [key in typeof SOLID_REFRESH | typeof SOLID_REFRESH_PREV]: Registry; }; -export type ESMRuntimeType = "esm" | "vite"; -export type StandardRuntimeType = "standard" | "webpack5" | "rspack-esm"; -export type RuntimeType = ESMRuntimeType | StandardRuntimeType; +/** + * Which hot API the compiled wrapper hands the runtime: `"vite"` — the + * `import.meta.hot` shape (`ESMHot`); `"standard"` — the `module.hot` / + * `import.meta.webpackHot` shape (`StandardHot`). + */ +export type RuntimeType = "vite" | "standard"; interface ESMHot { data: HotData; accept: (cb: (module?: unknown) => void) => void; invalidate: () => void; - decline: () => void; } interface StandardHot { @@ -392,8 +404,8 @@ function bailInvalidate(hot?: { invalidate?: () => void }): void { } } -type ESMDecline = [type: ESMRuntimeType, hot: ESMHot, inline?: boolean]; -type StandardDecline = [type: StandardRuntimeType, hot: StandardHot, inline?: boolean]; +type ESMDecline = [type: "vite", hot: ESMHot, inline?: boolean]; +type StandardDecline = [type: "standard", hot: StandardHot, inline?: boolean]; type Decline = ESMDecline | StandardDecline; export function $$decline(...[type, hot, inline]: Decline): void { @@ -402,15 +414,6 @@ export function $$decline(...[type, hot, inline]: Decline): void { return; } switch (type) { - case "esm": { - // Snowpack-style ESM treats invalidate as a full reload; prefer decline. - if (inline) { - hot.invalidate(); - } else { - hot.decline(); - } - break; - } case "vite": { // Vite ignores decline; accept-then-invalidate is the supported dance. if (inline) { @@ -422,15 +425,6 @@ export function $$decline(...[type, hot, inline]: Decline): void { } break; } - case "rspack-esm": - case "webpack5": { - if (inline) { - hot.invalidate!(); - } else { - hot.decline!(); - } - break; - } case "standard": { // Some module.hot implementations lack decline/invalidate entirely — // route through the configurable bail path. @@ -477,9 +471,9 @@ function shouldWarnAndDecline(): boolean { return true; } -function $$refreshESM(type: ESMRuntimeType, hot: ESMHot, registry: Registry): void { +function $$refreshESM(hot: ESMHot, registry: Registry): void { if (shouldWarnAndDecline()) { - $$decline(type, hot); + $$decline("vite", hot); } else if (hot.data) { hot.data[SOLID_REFRESH] = hot.data[SOLID_REFRESH] || registry; hot.data[SOLID_REFRESH_PREV] = registry; @@ -499,19 +493,19 @@ function $$refreshESM(type: ESMRuntimeType, hot: ESMHot, registry: Registry): vo }); } else { // No hot.data — nothing to persist registries on, so just decline. - $$decline(type, hot); + $$decline("vite", hot); } } -function $$refreshStandard(type: StandardRuntimeType, hot: StandardHot, registry: Registry): void { +function $$refreshStandard(hot: StandardHot, registry: Registry): void { if (shouldWarnAndDecline()) { - $$decline(type, hot); + $$decline("standard", hot); } else { const current = hot.data; if (current && current[SOLID_REFRESH]) { runAfterHydration(() => { if (patchRegistry(current[SOLID_REFRESH], registry)) { - $$decline(type, hot, true); + $$decline("standard", hot, true); } }); } @@ -522,8 +516,8 @@ function $$refreshStandard(type: StandardRuntimeType, hot: StandardHot, registry } } -type ESMRefresh = [type: ESMRuntimeType, hot: ESMHot, registry: Registry]; -type StandardRefresh = [type: StandardRuntimeType, hot: StandardHot, registry: Registry]; +type ESMRefresh = [type: "vite", hot: ESMHot, registry: Registry]; +type StandardRefresh = [type: "standard", hot: StandardHot, registry: Registry]; type Refresh = ESMRefresh | StandardRefresh; export function $$refresh(...[type, hot, registry]: Refresh): void { @@ -532,15 +526,12 @@ export function $$refresh(...[type, hot, registry]: Refresh): void { return; } switch (type) { - case "esm": case "vite": { - $$refreshESM(type, hot as ESMHot, registry); + $$refreshESM(hot as ESMHot, registry); break; } - case "standard": - case "webpack5": - case "rspack-esm": { - $$refreshStandard(type, hot as StandardHot, registry); + case "standard": { + $$refreshStandard(hot as StandardHot, registry); break; } } diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index f967aac2c..98425bc4f 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -14,7 +14,7 @@ import { throwerOf, ownerId } from "./signals.js"; -import { OBSERVE, ownerPath } from "@solidjs/signals"; +import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext } from "./shared.js"; import { IS_DEV, IS_OBSERVE, devCheck, emitFinding, errorText } from "./diagnostics.js"; import type { BoundaryEvent, BoundaryLive } from "./observe.js"; @@ -131,7 +131,7 @@ function ssrLoadingBoundary( // The core's walk (`_parent` + `_name`), the same one its diagnostics // make over these owners, so the record, the finding it may pair with // and the metric locate to the same ` › `. - const path = observed || timing !== undefined ? ownerPath(o) : undefined; + const path = observed || timing !== undefined ? OBSERVE!.ownerPath(o) : undefined; if (timing !== undefined) { // ASCII on the wire (a header value is a byte string); the adapter // renders the path with the artifact's ` › `. @@ -245,7 +245,7 @@ function ssrLoadingBoundary( ? `— the fragment rejected and the client re-renders it: ` : `— no boundary could contain it, the request failed: `) + errorText(err), - data: { handling, boundary: id, boundaryPath: ownerPath(o), error: err } + data: { handling, boundary: id, boundaryPath: OBSERVE!.ownerPath(o), error: err } }, // Located where it was THROWN (the owner it escaped), the boundary that // met it in `data` — the same two facts the server error hook carries. diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 768b6992f..371990323 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -1,7 +1,6 @@ import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "@solidjs/signals"; import { serverSlots } from "./observe.js"; import { installConsoleFooter } from "../console-footer.js"; -export { diagnosticGuideUrl } from "../console-footer.js"; // From mock signals (same exports that index.ts pulls from @solidjs/signals) export { @@ -55,7 +54,6 @@ export { enforceLoadingBoundary, untrack, configureClientErrors, - ownerPath, ROOT_ERROR_HOOK } from "./signals.js"; export type { ClientErrorContext, ClientErrorHook, ClientErrorsConfig } from "./signals.js"; @@ -180,4 +178,4 @@ export const DEV: Dev | undefined = IS_DEV ? _DEV : undefined; // The console face is the core's; the repair-guide footer under each first // report is this package's (console-footer.ts), installed here as on the client // so a server render's `[SERVER_WRITE]` points at the same skill section. -if (IS_DEV) installConsoleFooter(_DEV!); +if (IS_DEV) installConsoleFooter(); diff --git a/packages/solid/src/server/observe.ts b/packages/solid/src/server/observe.ts index bad21ecf0..6ba6ed9a3 100644 --- a/packages/solid/src/server/observe.ts +++ b/packages/solid/src/server/observe.ts @@ -130,7 +130,7 @@ export type BoundaryListener = (event: BoundaryEvent, live: BoundaryLive) => voi * The trace-provider slot — `OBSERVE.server.trace`. The CONTAINER is this * runtime's (a single replaceable provider, see `serverSlots`); what a * provider is — its argument, its answer — is the web runtime's, which - * augments this interface with `provide` (`TraceSlot` in `@solidjs/web`). + * augments this interface with `provide` (`trace.ts` in `@solidjs/web`). * Declared empty here so that runtime has one place to type it. */ export interface ServerTrace {} diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 402aaa691..804135629 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3006,24 +3006,15 @@ export function reportServerError( return { mapped: true, value: mapped }; } -/** Component labels up an owner chain, root first — the server owner's own fields. */ -function ownerLabels(subject: DiagnosticSubject): string[] | undefined { - if (!("_parent" in subject)) return undefined; - return ownerChainLabels(subject); -} - /** - * Root-first names of the owners enclosing `subject` — the server twin of - * the core's `ownerPath` (` › › effect`). Server signals do - * not register an owner, so a signal subject answers `undefined` here where - * the client hops to its registering owner. + * Component labels up an owner chain, root first — the server owner's own + * fields (`_parent` + `_name`), the same walk the core's `OBSERVE.ownerPath` + * makes over these owners. Server signals do not register an owner, so a + * signal subject answers `undefined` here where the client hops to its + * registering owner. */ -export function ownerPath(subject: DiagnosticSubject | null | undefined): string[] | undefined { - if (!subject || !("_parent" in subject)) return undefined; - return ownerChainLabels(subject); -} - -function ownerChainLabels(subject: DiagnosticSubject): string[] | undefined { +function ownerLabels(subject: DiagnosticSubject): string[] | undefined { + if (!("_parent" in subject)) return undefined; const path: string[] = []; for (let owner: any = subject; owner; owner = owner._parent) { const name = owner._name; diff --git a/packages/solid/test/refresh.spec.ts b/packages/solid/test/refresh.spec.ts index 71c9a17b9..37654396d 100644 --- a/packages/solid/test/refresh.spec.ts +++ b/packages/solid/test/refresh.spec.ts @@ -6,7 +6,6 @@ import { createSignal, flush, getOwner, - ownerPath, OBSERVE } from "../src/index.js"; import { attribution } from "../src/attribution.js"; @@ -107,7 +106,7 @@ describe("$$component proxy owner paths", () => { // locates it — through the owners above it. createMemo( () => { - inner = ownerPath(getOwner()); + inner = OBSERVE!.ownerPath(getOwner()); return 0; }, { name: "total" } diff --git a/packages/web/performance-tracks/src/index.ts b/packages/web/performance-tracks/src/index.ts index db3a8ec86..5114d4c20 100644 --- a/packages/web/performance-tracks/src/index.ts +++ b/packages/web/performance-tracks/src/index.ts @@ -13,7 +13,7 @@ * Chrome's own main-thread and network tracks, using the panel's * extensibility API (`console.timeStamp` with a track, `performance.measure` * with `detail.devtools`). Same records, same formatters (`formatRerun`, - * `formatOrigin`, `ownerPath`), so what the human sees on the timeline is + * `formatOrigin`, `OBSERVE.ownerPath`), so what the human sees on the timeline is * what the agent read — and every span answers "why", not only "how long". * * Every entry is emitted RETROACTIVELY, from the `performance.now()` @@ -31,29 +31,25 @@ * disable(); * ``` */ -import { OBSERVE, diagnosticGuideUrl, ownerPath } from "solid-js"; -import type { - ChangeOrigin, - ChangeRecord, - CreateEvent, - DiagnosticEvent, - DiagnosticSubject, - EffectRunEvent, - FallbackEvent, - FlightEvent, - FlushEvent, - HeldWrite, - HoldEvent, - InteractionEvent, - NavigationEvent, - RerunEvent -} from "solid-js"; +import { DEV, OBSERVE } from "solid-js"; +import type { ChangeOrigin, DiagnosticEvent, DiagnosticSubject } from "solid-js"; import type { CallEvent, CallLive, FrameEvent } from "@solidjs/web"; import { attribution, formatOrigin, formatRerun, - type AttributionOptions + type AttributionOptions, + type ChangeRecord, + type CreateEvent, + type EffectRunEvent, + type FallbackEvent, + type FlightEvent, + type FlushEvent, + type HeldWrite, + type HoldEvent, + type InteractionEvent, + type NavigationEvent, + type RerunEvent } from "solid-js/attribution"; // Replaced per build (see rollup.config.js); module consts so the gates @@ -92,8 +88,6 @@ export interface PerformanceTracksOptions { * Each path falls back to the other where its API is missing. */ rich?: boolean; - /** The track group name. Default `"Solid"`. */ - group?: string; /** * Drop what a shared trace should not carry: value previews on causes * and held writes, and target text on anything but a `button` or `a` @@ -168,6 +162,9 @@ interface Emitter { dispose(): void; } +/** The track group every track sits under — the name the panel shows for Solid's rows. */ +const GROUP = "Solid"; + /** * The tracks, in display order. Each is seeded with a zero-length entry at * t=0.003 when the adapter starts, so the panel lays them out in this @@ -225,7 +222,7 @@ export function enablePerformanceTracks(options: PerformanceTracksOptions = {}): const observe = OBSERVE; if (observe === undefined) return noop; if (typeof performance !== "object" || typeof performance.now !== "function") return noop; - const emitter = createEmitter(options.group ?? "Solid", options.rich ?? IS_DEV); + const emitter = createEmitter(options.rich ?? IS_DEV); if (emitter === undefined) return noop; const minMs = options.minMs ?? (IS_DEV ? 0 : 0.05); @@ -297,7 +294,7 @@ let instance: Instance | null = null; * here and never reaches the engine's record loop — the span is dropped, * and dev says so once. */ -function createEmitter(group: string, rich: boolean): Emitter | undefined { +function createEmitter(rich: boolean): Emitter | undefined { const hasMeasure = typeof performance.measure === "function"; const hasTimeStamp = typeof console !== "undefined" && typeof console.timeStamp === "function"; let emitter: Emitter; @@ -320,7 +317,7 @@ function createEmitter(group: string, rich: boolean): Emitter | undefined { const devtools: Record = { dataType: "track-entry", track, - trackGroup: group, + trackGroup: GROUP, color }; if (tooltip !== undefined) devtools.tooltipText = tooltip; @@ -350,7 +347,7 @@ function createEmitter(group: string, rich: boolean): Emitter | undefined { emitter = { rich: false, span(label, start, end, track, color, _tooltip, _properties, task) { - guarded(() => timeStamp(label, start, end, track, group, color), task); + guarded(() => timeStamp(label, start, end, track, GROUP, color), task); }, mark(label, _color, _tooltip, _properties, _issue, task) { // The one-argument form: a marker on the Timings track, now. @@ -455,7 +452,7 @@ class Painter { rerun(event: RerunEvent, subject: DiagnosticSubject): void { collectRoots(event.causes, this.roots); if (!event.changed) this.unchanged++; - const node = describe(event.nodeName, ownerPath(subject)); + const node = describe(event.nodeName, OBSERVE!.ownerPath(subject)); this.names.set(event.nodeId, node.short); if (event.totalMs < this.minMs) return; const track = event.nodeKind === "effect" ? TRACKS.effects : TRACKS.memos; @@ -512,7 +509,7 @@ class Painter { * `` growing) shows what it mounted beside what it re-ran. */ create(event: CreateEvent, subject: DiagnosticSubject): void { - const node = describe(event.nodeName, ownerPath(subject)); + const node = describe(event.nodeName, OBSERVE!.ownerPath(subject)); this.names.set(event.nodeId, node.short); if (event.totalMs < this.minMs) return; let properties: Properties | undefined; @@ -552,7 +549,7 @@ class Painter { */ effect(event: EffectRunEvent, subject: DiagnosticSubject): void { if (event.durationMs < this.minMs) return; - const node = describe(event.nodeName, ownerPath(subject)); + const node = describe(event.nodeName, OBSERVE!.ownerPath(subject)); let properties: Properties | undefined; if (this.rich) { properties = [["Duration", ms(event.durationMs)]]; @@ -617,13 +614,16 @@ class Painter { } } } - properties.push(["Guide", diagnosticGuideUrl(event.code)]); + // The repair guide is dev guidance (`DEV.guideUrl`); an observe build + // in rich mode paints the finding without the link. + const guide = IS_DEV ? DEV!.guideUrl(event.code) : undefined; + if (guide) properties.push(["Guide", guide]); if (event.severity !== "info") { issue = { name: `Solid: ${event.code}`, severity: event.severity === "error" ? "error" : "warning", description: tooltip, - learnMoreUrl: diagnosticGuideUrl(event.code) + learnMoreUrl: guide }; } } diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 62befc415..1c496f776 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -137,7 +137,7 @@ export type { } from "./observe.js"; // The trace context's types (`getTraceContext()`, `OBSERVE.server.trace`), // with the `ServerObserve.trace` augmentation, for the same reason. -export type { TraceContext, TraceProvider, TraceSlot } from "./trace.js"; +export type { TraceContext, TraceProvider } from "./trace.js"; export type { ServerFunction, diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index 8f9b2b1f4..07f5ec11b 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -38,7 +38,7 @@ export type { InvocationListener, InvocationLive } from "./observe.js"; -export type { TraceContext, TraceProvider, TraceSlot } from "./trace.js"; +export type { TraceContext, TraceProvider } from "./trace.js"; export type { JSX } from "../jsx/jsx.js"; export { diff --git a/packages/web/src/observe.ts b/packages/web/src/observe.ts index 54d4cf771..d7262ac2d 100644 --- a/packages/web/src/observe.ts +++ b/packages/web/src/observe.ts @@ -18,7 +18,7 @@ // Everything here folds out of the prod artifacts behind the // `"_SOLID_OBSERVE_"` literal (replaced per build, like the `_SOLID_DEV_` // gates): prod never reaches for the channel. -import type { AttributionHooks, ChangeOrigin, Records } from "solid-js"; +import type { ChangeOrigin, Records } from "solid-js"; import type { RequestEvent } from "./server.js"; // Replaced per build; a module const (not an inline literal) so the typed @@ -28,6 +28,16 @@ const IS_OBSERVE = "_SOLID_OBSERVE_" as unknown as boolean; const RECORDS = Symbol.for("@solidjs/signals/observe/records"); const ATTRIBUTION = Symbol.for("@solidjs/signals/observe/attribution"); +/** + * The one method this runtime calls on the engine registered under + * `ATTRIBUTION` — the same `currentOrigin` `OBSERVE.attribution` exposes. + * Typed structurally: the engine's hook table is the core's internal + * contract, and the registered name is the contract here. + */ +interface OriginSource { + currentOrigin(): ChangeOrigin | undefined; +} + /** * The records channel — `OBSERVE.records` — or `undefined` outside observe * builds and where no observing core has loaded. An emitter's first check. @@ -48,7 +58,7 @@ function currentOrigin(): ChangeOrigin | undefined { // Gated like `records()`: the literal folds the reach (and the registered // name with it) out of prod, where the emitter that calls this is dead. if (!IS_OBSERVE) return undefined; - const hooks = (globalThis as { [ATTRIBUTION]?: AttributionHooks })[ATTRIBUTION]; + const hooks = (globalThis as { [ATTRIBUTION]?: OriginSource })[ATTRIBUTION]; return hooks === undefined ? undefined : hooks.currentOrigin(); } diff --git a/packages/web/src/trace.ts b/packages/web/src/trace.ts index 14c3cdc4b..ac6b215dd 100644 --- a/packages/web/src/trace.ts +++ b/packages/web/src/trace.ts @@ -92,9 +92,6 @@ declare module "solid-js" { } } -/** The provider slot's type, as this package names it — `solid-js`'s `ServerTrace`, filled in above. */ -export type TraceSlot = ServerTrace; - /** * A timed span of the request's server work, carried to the browser as a * `Server-Timing` metric (`;dur=;desc=""`) beside the trace @@ -141,7 +138,7 @@ const IS_OBSERVE = "_SOLID_OBSERVE_" as unknown as boolean; // contract. const PROVIDER = Symbol.for("solid-js/observe/server/provider"); -type SlotState = TraceSlot & { [PROVIDER]?: TraceProvider }; +type SlotState = ServerTrace & { [PROVIDER]?: TraceProvider }; function currentProvider(): TraceProvider | undefined { if (!IS_OBSERVE || OBSERVE === undefined) return undefined; diff --git a/packages/web/test/observe.type-tests.ts b/packages/web/test/observe.type-tests.ts index a6e357ab7..55f73bd3e 100644 --- a/packages/web/test/observe.type-tests.ts +++ b/packages/web/test/observe.type-tests.ts @@ -34,8 +34,7 @@ import type { InvocationEvent, InvocationLive, TraceContext, - TraceProvider, - TraceSlot + TraceProvider } from "@solidjs/web"; import { getTraceContext } from "@solidjs/web"; @@ -180,9 +179,8 @@ off(); // The trace-provider slot: the member is solid-js's (`ServerTrace`), the // `provide` on it is this package's augmentation (trace.ts) — a second // solid-js interface filled in from a second module; both merges land. -observe.server.trace satisfies TraceSlot; observe.server.trace satisfies ServerTrace; -const slot: TraceSlot = observe.server.trace; +const slot: ServerTrace = observe.server.trace; slot.provide satisfies (provider: TraceProvider) => () => void; const provider: TraceProvider = request => { request satisfies Request | undefined; diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index 5697ddf29..d45c6721d 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -303,12 +303,6 @@ describe("enablePerformanceTracks", () => { } }); - test("the group name is an option", () => { - const { seen } = measures(); - enable({ group: "My App" }); - expect(seen.every(m => m.group === "My App")).toBe(true); - }); - test("click → write → memo → effect: one span per record, on the record's own clock", () => { const { on } = measures(); enable(); @@ -1364,13 +1358,13 @@ describe("enablePerformanceTracks", () => { test("one instance per page: a second enable joins it, and the last release tears it down", () => { const { seen } = measures(); const first = enable(); - const second = enable({ group: "Other" }); + const second = enable({ rich: true }); const [n, setN] = createSignal(0, { name: "n" }); createRoot(() => createRenderEffect(n, () => {}, { name: "reader" })); flush(); setN(1); flush(); - // Painted once, in the first call's group — not twice. + // Painted once, by the first call's instance — not twice. const painted = seen.filter(m => m.label.endsWith("reader")); expect(painted).toHaveLength(1); expect(painted[0].group).toBe("Solid"); diff --git a/packages/web/test/server/diagnostics-server-scenario.spec.tsx b/packages/web/test/server/diagnostics-server-scenario.spec.tsx index 4fe9cd945..a46dd7405 100644 --- a/packages/web/test/server/diagnostics-server-scenario.spec.tsx +++ b/packages/web/test/server/diagnostics-server-scenario.spec.tsx @@ -9,10 +9,10 @@ // every frame stream produced, joined by `invocation.boundary` → // `boundary.id` and `frame.id` = `invocation.id`. // -// The harness depends on `@solidjs/signals` alone and reads the channel by -// its contract, so this suite is also where its mirrored record types are -// pinned to the runtime's (compile-time, below) — the client's `"call"` -// included, since the mirrors are one package. +// The harness reads the channel by its contract and types the tables off the +// runtimes' own catalogue (`RecordEvent` — `solid-js`'s `"boundary"` / +// `"recovery"`, `@solidjs/web`'s `"invocation"` / `"frame"` / `"call"`), so +// the shape pins below are against those names directly. // // Imports source (the dev tier) and compiles with `sourceNames` // (vite.config.server.mjs); the server-functions runtime is the built @@ -20,56 +20,37 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { Loading, renderToStream, useHead } from "@solidjs/web"; -import type { - CallEvent, - FrameAppliedEvent, - FrameEvent, - FrameProducedEvent, - InvocationEvent, - JSX -} from "@solidjs/web"; -import { createMemo, createSignal, type BoundaryEvent } from "solid-js"; +import type { CallEvent, FrameEvent, InvocationEvent, JSX } from "@solidjs/web"; +import { createMemo, createSignal, type BoundaryEvent, type RecoveryEvent } from "solid-js"; import { artifactToJSONL, captureArtifact, DiagnosticsAssertionError, expectDiagnostic, expectNoDiagnostics, - type BoundaryRecord, - type CallRecord, - type FrameAppliedRecord, - type FrameProducedRecord, - type FrameRecord, - type InvocationRecord + type ArtifactRecords } from "@solidjs/diagnostics"; import type * as prodRuntime from "@solidjs/web/server-functions/server"; -// --- The mirrored record types are the runtime's, both ways -------------- +// --- The artifact's record tables are the runtimes' event types ----------- // Mutual assignability pins every required member; the key sets pin the // optional ones too (an optional field missing on one side still assigns). type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const boundaryShape: Same = true; -const boundaryKeys: Same = true; -const invocationShape: Same = true; -const invocationKeys: Same = true; -const callShape: Same = true; -const callKeys: Same = true; -const frameShape: Same = true; -const producedShape: Same = true; -const producedKeys: Same = true; -const appliedShape: Same = true; -const appliedKeys: Same = true; +const boundaryShape: Same = true; +const recoveryShape: Same = true; +const invocationShape: Same = true; +const frameShape: Same = true; +const callShape: Same = true; +const tableKeys: Same< + keyof ArtifactRecords, + "boundary" | "recovery" | "invocation" | "frame" | "call" +> = true; void boundaryShape; -void boundaryKeys; +void recoveryShape; void invocationShape; -void invocationKeys; -void callShape; -void callKeys; void frameShape; -void producedShape; -void producedKeys; -void appliedShape; -void appliedKeys; +void callShape; +void tableKeys; const RequestContext = Symbol.for("solid.RequestContext"); let runtime: typeof prodRuntime; @@ -151,7 +132,7 @@ describe("captureArtifact over a server render", () => { expect(html).toContain("Ada"); // The findings, located by component. - expect(artifact.formatVersion).toBe(7); + expect(artifact.formatVersion).toBe(8); expect(artifact.timeOrigin).toBe(performance.timeOrigin); expect(artifact.scenario).toBe("profile page"); expectDiagnostic(artifact, "HEAD_TAG_INVALID", { count: 1 }); @@ -197,10 +178,10 @@ describe("captureArtifact over a server render", () => { .map(line => JSON.parse(line)); expect(lines[0]).toMatchObject({ type: "meta", - formatVersion: 7, + formatVersion: 8, timeOrigin: performance.timeOrigin, diagnosticCount: 2, - recordCounts: { boundary: 1, invocation: 1, frame: 0, call: 0 } + recordCounts: { boundary: 1, recovery: 0, invocation: 1, frame: 0, call: 0 } }); expect(lines.filter(l => l.type === "boundary")).toEqual([ { type: "boundary", ...boundaries[0] } @@ -297,7 +278,13 @@ describe("captureArtifact over a server render", () => { attribution: false }); expectNoDiagnostics(artifact); - expect(artifact.records).toEqual({ boundary: [], invocation: [], frame: [], call: [] }); + expect(artifact.records).toEqual({ + boundary: [], + recovery: [], + invocation: [], + frame: [], + call: [] + }); }); test("records outside the capture are not in it", async () => { @@ -310,6 +297,12 @@ describe("captureArtifact over a server render", () => { } await stream(() => ); const { artifact } = await captureArtifact(() => {}, { attribution: false }); - expect(artifact.records).toEqual({ boundary: [], invocation: [], frame: [], call: [] }); + expect(artifact.records).toEqual({ + boundary: [], + recovery: [], + invocation: [], + frame: [], + call: [] + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21ed09cc3..8b9de73fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,12 +389,18 @@ importers: specifier: workspace:* version: link:../signals devDependencies: + '@solidjs/web': + specifier: workspace:* + version: link:../web '@types/node': specifier: ^25.0.8 version: 25.6.0 rimraf: specifier: ^5.0.1 version: 5.0.10 + solid-js: + specifier: workspace:* + version: link:../solid typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index fb7bc8fcd..b03d2ec94 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -1019,7 +1019,14 @@ module.exports = [ // minified (see the core floor note): recompute's `finally` mask carries // REACTIVE_DISPOSED and the pass returns on it — `clearDeps`, the flight // retire, the lane restore. 0 B in solid and web. - limit: "12.60 KB", + // Observability surface prune (2026-09-24): 12.60 -> 12.61 KB, measured + // at 12,603 B against `records-channel`'s 12,581 (+22 B; +3 over the + // cap). Layout drift only: the minified output is the same 35,497 B and + // differs only in which short names the mangler hands out — the prod + // export sets changed (`ownerPath`/`diagnosticGuideUrl` off `solid-js`, + // `setConsoleFooter` on `@solidjs/signals`), all of it tree-shaken here. + // An independent gzip -9 of the two bundles: 13,868 vs 13,866. 0 B retained. + limit: "12.61 KB", modifyEsbuildConfig }, { @@ -2259,7 +2266,15 @@ module.exports = [ // `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", + // Observability surface prune (2026-09-24): 31.70 -> 31.71 KB, measured + // at 31,702 B against `records-channel`'s 31,689 (+13 B; +2 over the + // cap). +6 B minified: `OBSERVE.ownerPath` joins the observe object + // (the walk was already retained by the server/perf-tracks callers; the + // property is the cost) and `attribution.install` leaves it — the engine + // installs through the core's own `setAttributionHooks`. The guide URL + // moved to `DEV.guideUrl`, so its string is off the observe object: the + // tier scenario above is -51 B (17,648). The rest is mangler layout. + limit: "31.71 KB", modifyEsbuildConfig: observeEsbuildConfig }, { diff --git a/turbo.json b/turbo.json index 670e5779d..1a6bad9f2 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,15 @@ "outputs": ["dist/types/**"] }, "@solidjs/diagnostics#build": { - "dependsOn": ["@solidjs/signals#build", "@solidjs/signals#types"], + // Runtime imports are `@solidjs/signals` only; the record tables are + // typed off `solid-js`'s and `@solidjs/web`'s catalogue augmentations, + // so their generated declarations must exist first. + "dependsOn": [ + "@solidjs/signals#build", + "@solidjs/signals#types", + "solid-js#types", + "@solidjs/web#types" + ], "outputs": ["dist/**"] }, "solid-js#build": {