From 338bdae70c5208d0347e49f04ec1136107f09349 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 24 Sep 2026 15:38:54 -0700 Subject: [PATCH 1/3] feat!: consolidate diagnostic codes; sourceNames follows dev in both compilers Diagnostic codes: - HUGE_FAN_OUT absorbs WIDE_WRITE; the write-side attribution now ships as `data.write` on the same code, and the engine emits it directly. - SERVER_ERROR_SANITIZED replaces SERVER_FN_ERROR_SANITIZED and SSR_ERROR_SANITIZED, distinguished by `data.source`. - SSR_BOUNDARY_WATERFALL split out of ASYNC_WATERFALL (which drops `data.side`). - AttributionOptions.wideWrites renamed to fanOut (`number | false`, default 250); new FanOutWrite type. Compilers: - `sourceNames` defaults to `dev` in both @solidjs/babel-plugin and @solidjs/compiler; object-form kinds default to `dev`; dev SSR keeps `createComponent` naming. - Primitive naming stays with the native standalone pass; neither JSX compiler exposes a `primitives` key. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/diagnostic-code-consolidation.md | 11 +++ .changeset/source-names-parity.md | 8 ++ documentation/performance-experiments.md | 6 +- documentation/plans/observe-tier-plan.md | 2 +- documentation/plans/server-dev-build-plan.md | 6 +- .../production-observability-sketch.md | 20 +++-- documentation/solid-2.0/08-dev-diagnostics.md | 57 ++++++------ .../solid-2.0/10-server-functions.md | 2 +- documentation/solid-2.0/12-ssr-http.md | 2 +- packages/babel-plugin/README.md | 6 +- packages/babel-plugin/src/config.ts | 24 ++++-- .../walkValidation/output.js | 28 +++++- .../test/dom-source-names.spec.js | 43 ++++++++++ packages/babel-plugin/test/ssr-props.spec.js | 5 +- packages/compiler/README.md | 4 +- .../walkValidation/output.js | 8 +- packages/compiler/__tests__/transform.test.js | 30 +++++++ packages/compiler/src/compiler.rs | 21 ++++- packages/compiler/src/config.rs | 6 +- packages/compiler/src/node_adapter.rs | 24 +++--- packages/compiler/types.d.ts | 17 +++- packages/diagnostics/README.md | 2 +- .../diagnostics/skills/agent-loops/SKILL.md | 4 +- packages/signals/src/core/attribution.ts | 72 ++++++---------- packages/signals/src/core/dev.ts | 40 ++++++--- .../tests/attribution-benchmark-eval.test.ts | 14 +-- ...te.test.ts => attribution-fan-out.test.ts} | 59 +++++++++---- packages/signals/tests/attribution.test.ts | 2 +- packages/signals/tests/diagnostics.test.ts | 2 +- .../skills/reactivity-diagnostics/SKILL.md | 86 ++++++++++--------- packages/solid/src/console-footer.ts | 4 +- packages/solid/src/internal.ts | 2 +- packages/solid/src/server/hydration.ts | 27 +++--- packages/solid/src/server/signals.ts | 13 +-- .../test/server/server-diagnostics.spec.ts | 2 +- packages/web/server-functions/src/server.ts | 13 +-- packages/web/test/dev-warning.spec.tsx | 2 +- packages/web/test/performance-tracks.spec.tsx | 4 +- .../diagnostics-server-scenario.spec.tsx | 6 +- .../server/server-boundary-records.spec.tsx | 30 ++++--- .../test/server/server-diagnostics.spec.tsx | 11 ++- .../server/ssr-error-sanitization.fixture.mjs | 1 + .../server/ssr-error-sanitization.spec.tsx | 23 ++--- scripts/size/.size-limit.js | 5 +- 44 files changed, 474 insertions(+), 280 deletions(-) create mode 100644 .changeset/diagnostic-code-consolidation.md create mode 100644 .changeset/source-names-parity.md rename packages/signals/tests/{attribution-wide-write.test.ts => attribution-fan-out.test.ts} (54%) diff --git a/.changeset/diagnostic-code-consolidation.md b/.changeset/diagnostic-code-consolidation.md new file mode 100644 index 000000000..47cce0d42 --- /dev/null +++ b/.changeset/diagnostic-code-consolidation.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +--- + +Diagnostic code consolidation. Codes are public API (the Sentry fingerprint roots); three renames. + +- `WIDE_WRITE` is folded into `HUGE_FAN_OUT` — one code, one threshold story. The core's always-on check warns at 2000 subscribers; the attribution engine's `fanOut` threshold (default 250, was `wideWrites`) warns earlier while it is enabled and reports through the same emitter, so `data.count` is the subscriber count and, from the engine, `data.write` says which write reached it (`"write"`, `"refresh"`, `"async"`). One WeakMap dedupes both reporters (re-warn after another 500). `AttributionOptions.wideWrites` → `fanOut`. +- `SERVER_FN_ERROR_SANITIZED` and `SSR_ERROR_SANITIZED` are one code, `SERVER_ERROR_SANITIZED` — the same fact from two roads. `data.source` is `"server-function"` (severity `error`, from `@solidjs/web/server-functions`) or `"ssr"` (severity `info`, from the SSR ``/rejection path); `data.error` is the original and `data.wire` the replacement on both. +- `ASYNC_WATERFALL` is client-only again: the server's boundary-passes verdict is its own code, `SSR_BOUNDARY_WATERFALL` (kind `ssr`; `info` for two sequential waits, `warn` for three or more; `data: { boundary, passes, sequentialMs }`). `data.side` is gone with it. diff --git a/.changeset/source-names-parity.md b/.changeset/source-names-parity.md new file mode 100644 index 000000000..7e3df99aa --- /dev/null +++ b/.changeset/source-names-parity.md @@ -0,0 +1,8 @@ +--- +"@solidjs/compiler": patch +"@solidjs/babel-plugin": patch +--- + +`sourceNames` follows `dev` in both JSX compilers. Unset, every kind (`components`, `bindings`) is on when `dev: true` and off otherwise; `true`/`false` sets every kind (`sourceNames: false` opts a dev build out); the object form picks, and each kind it leaves unspecified follows `dev` (previously `false`). Production output (`dev: false`) is byte-identical with the option set or unset. `@solidjs/babel-plugin`'s `PluginConfig.sourceNames` is now optional. Dev builds label components (`createComponent(Home, props, "Home")`) and binding effects (`span.children`) out of the box, so diagnostics and the Performance panel read as source without build-tool configuration. + +Primitive naming (`createSignal(0, { name: "count" })`) stays with `@solidjs/compiler`'s standalone `transformSourceNames` pass, which the build tool runs on every module independently of the JSX compiler; it is not a JSX-transform kind, and the READMEs and RFC now say so. diff --git a/documentation/performance-experiments.md b/documentation/performance-experiments.md index 858f84d2b..9703764a6 100644 --- a/documentation/performance-experiments.md +++ b/documentation/performance-experiments.md @@ -7079,9 +7079,9 @@ null)` leaves a `...false` spread in prod (rollup folds the conditional pass with a module counter bumped per first-touch link, and CodSpeed priced that). `HUGE_FAN_OUT` / `HUGE_FAN_IN` therefore fire on the change / the recompute rather than the link, deduped through a - `WeakMap` (once per node, again after +500). `WIDE_WRITE` counts the - subscriber list itself on the write (engine-only walk) and hands over to - `HUGE_FAN_OUT` at 2000. + `WeakMap` (once per node, again after +500). The attribution engine's + lower-threshold `HUGE_FAN_OUT` check counts the subscriber list itself on + the write (engine-only walk) and hands over to the core's at 2000. - **Prod byte-identical** modulo comments and the removed `...false ? {…} : options` spread (`diff -w` of `dist/prod` before/after, comment lines stripped: only that hunk). diff --git a/documentation/plans/observe-tier-plan.md b/documentation/plans/observe-tier-plan.md index 0bc384fb5..eeac58aeb 100644 --- a/documentation/plans/observe-tier-plan.md +++ b/documentation/plans/observe-tier-plan.md @@ -107,7 +107,7 @@ Site migration `__DEV__` → `__OBSERVE__` (everything not listed stays | `core/scheduler.ts` | 223, 488, 1259, 1290 | hooks | | `core/action.ts` | 140, 144, 147 | hooks | | `core/effect.ts` | 210 | hooks | -| `core/graph.ts` | 18, 194 | edge counters (`WIDE_WRITE`) | +| `core/graph.ts` | 18, 194 | edge counters (`HUGE_FAN_OUT`) | | `boundaries.ts` | 334, 372 | hooks | | `map.ts` | 281, 290, 331 (hooks); 76, 102, 370 (name plumbing) | hooks; labels | | `signals.ts` | 373, 1085 (`registerGraph` → `_owner` half); 515, 557, 612, 677, 1189 (name defaults) | labels | diff --git a/documentation/plans/server-dev-build-plan.md b/documentation/plans/server-dev-build-plan.md index d99f90072..db368837d 100644 --- a/documentation/plans/server-dev-build-plan.md +++ b/documentation/plans/server-dev-build-plan.md @@ -189,8 +189,8 @@ Decision: **reuse `@solidjs/signals`'s channel, do not fork it.** > boundary's routing, `failed` from the root), `SSR_SUBTREE_ABANDONED` > (`abandonSubtree` with pending work discarded), `SSR_STREAM_ABANDONED` > (`abandon("consumer" | "sink")`), `LATE_HEADER_WRITE` (recorded, then the -> existing dev throw / prod log), `SERVER_FN_ERROR_SANITIZED` -> (`sanitizeServerError`, `data.error` the original). The server entry also +> existing dev throw / prod log), `SERVER_ERROR_SANITIZED` +> (`sanitizeServerError`, `data.source: "server-function"`, `data.error` the original). The server entry also > installs the client's repair-guide console footer (`src/console-footer.ts`, > shared). Specs: `packages/solid/test/server/server-diagnostics.spec.ts`, > `packages/web/test/server/server-diagnostics.spec.tsx`, @@ -334,7 +334,7 @@ becomes the contract test for server codes. > > **(b) Checks off the record.** `ssrLoadingBoundary` derives two dev checks > from the same facts the `"boundary"` record carries (the clock now runs in -> dev without a listener): `ASYNC_WATERFALL` with `data.side: "server"` — +> dev without a listener): `SSR_BOUNDARY_WATERFALL` — > `passes - 1` sequential flights, exact where the client's proof is > inferred, same thresholds (2 → `info`, structured only; 3+ → `warn`) — and > a new `SSR_CLIENT_CONTENT_MASKED` (`warn`, `ssr`) for a client-only outcome diff --git a/documentation/proposals/production-observability-sketch.md b/documentation/proposals/production-observability-sketch.md index 8a8e2ed79..5fc49fc8c 100644 --- a/documentation/proposals/production-observability-sketch.md +++ b/documentation/proposals/production-observability-sketch.md @@ -43,7 +43,7 @@ causality off structure rather than inferring it: `paintedDuringHold`). `SILENT_HOLD` is a responsiveness verdict no RUM tool has. - **Verdicts with prescribed repairs**: `ASYNC_WATERFALL` (graph-proven - sequential flights), `WIDE_WRITE`, `HOT_SCOPE_FANOUT`, `UNSTABLE_MEMO_OUTPUT`, + sequential flights), `HUGE_FAN_OUT`, `HOT_SCOPE_FANOUT`, `UNSTABLE_MEMO_OUTPUT`, `WIDE_SCOPE_DEPS`, `SILENT_HOLD` — stable codes, `ownerPath`, and message text that names the fix. @@ -105,7 +105,7 @@ the sites survive the build. The surface is small and well-delineated: needed for anything to be legible. - **Edge counts**: none stored. Fan-out is counted by the notify walk (`insertSubs`), fan-in by the recompute pass (`link()` into one module - counter) — the always-on graph-size warnings and `WIDE_WRITE` read those. + counter) — the always-on graph-size warnings and the engine's lower-threshold `HUGE_FAN_OUT` read those. (An earlier draft kept live `_subCount`/`_depCount` fields per node; they forked node shapes and were removed.) - **Web runtime (`@solidjs/web`)**: 3 `withInteraction` wrap sites @@ -174,7 +174,7 @@ A third build flavor alongside `dist/dev` and `dist/prod`: `dist/profiling` - `__DEV__: false` (no strict-read checks, no owner-scope errors, no invariants, no console reporting) but a new `__OBSERVE__: true` flag that keeps exactly: the `attrHooks` call sites, `noteGraphLink`/`unnoteGraphLink` - counters (needed by `WIDE_WRITE`), the `_name` field, and `emitDiagnostic` + counters (needed by the engine's `HUGE_FAN_OUT` threshold), the `_name` field, and `emitDiagnostic` with `DiagnosticEvent` typing. The engine itself (`attribution.ts`) stays pay-for-use: not loaded unless a consumer calls `enable()`. - `@solidjs/web` mirrors it: the `withInteraction` wrappers at @@ -309,7 +309,7 @@ the PII surface — see §6. - **Per-interaction cost cap**: the adapter drops rerun children above a count and keeps aggregates. Findings are never dropped (they are rare and already deduped once-per-node by the engine). -- **Thresholds are the engine's** (`hotRuns`, `hotTime`, `wideWrites`, +- **Thresholds are the engine's** (`hotRuns`, `hotTime`, `fanOut`, `holds: { infoMs, warnMs }`, `waterfalls.minFlightMs`). The adapter may raise them for prod; it must not lower them below dev defaults, or prod reports things dev never showed the developer. @@ -424,7 +424,7 @@ Vendor (e.g. `@sentry/solid`): 3. Scrubbing defaults per §6 hooked into their existing data-collection controls. 4. Product side: new performance-issue detectors for `SILENT_HOLD`, - `ASYNC_WATERFALL`, `HOT_SCOPE_FANOUT`, `WIDE_WRITE` with the engine's + `ASYNC_WATERFALL`, `HOT_SCOPE_FANOUT`, `HUGE_FAN_OUT` with the engine's repair text as the "how to fix" body; per-interaction cost/hold rows in the INP/Web Vitals view. Their autofix/agent surface can consume the `reactivity-diagnostics` skill directly — the repairs are already written @@ -612,9 +612,11 @@ are proposals; thresholds follow the engine's tiering (`info` advisory, ### 10.2 Server / SSR -- `SSR_BOUNDARY_WATERFALL` — _prod verdict, new._ The client engine's - `ASYNC_WATERFALL` logic (causal chain + origin post-dates upstream landing + - duration gate) applied to server flights within one request. Server flights +- `SSR_BOUNDARY_WATERFALL` — _prod verdict; today a dev-only check._ The + shipped code reads `passes - 1` off the `` boundary record in dev + (RFC 08); this proposes the client engine's `ASYNC_WATERFALL` logic (causal + chain + origin post-dates upstream landing + duration gate) applied to + server flights within one request, in observe builds too. Server flights are already per-boundary awaits (`hydration.ts:176–317`); the server facade needs `flightStart`/`asyncEnd`-equivalent hooks. This is exactly what React's Server Requests track shows visually and does not judge. @@ -638,7 +640,7 @@ are proposals; thresholds follow the engine's tiering (`info` advisory, declared in one scope was retracted before commit (the ledger's own semantics). Advisory: it is legal, but a retract-heavy request is usually a boundary doing HTTP work it shouldn't. -- `SERVER_FN_ERROR_SANITIZED` — _wiring, new._ A server function threw a +- `SERVER_ERROR_SANITIZED` (`data.source: "server-function"`) — _wiring, new._ A server function threw a plain error that prod sanitized before encoding. The telemetry side keeps the original message + stack (server-only), the wire keeps the sanitized form. Closes the "prod errors are opaque" gap without weakening the wire. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index de5d17132..e8db67267 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -13,7 +13,7 @@ Diagnostics can also be programmatically observed via `OBSERVE.diagnostics.subsc 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 compiler's `sourceNames.bindings` is on, which the Vite plugin enables alongside `components`; a primitive reads as the identifier it was declared as (`count`, `doubled`, `todos.title`, `createCounter.value` inside a composed primitive) when the compiler's `transformSourceNames` pass runs (the Vite plugin's `sourceNames.primitives`, on by default in its dev and `observe` postures, for `.ts`/`.js` modules as well as components) — 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 compiler's `sourceNames` option is on (`sourceNames.components`: `createComponent(Home, props, "Home")` — the Vite plugin enables it for the dev and `observe` postures, 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. +- 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`. @@ -410,13 +410,11 @@ The owner passed to `runWithOwner` has already been disposed. Any reactive primi #### `HUGE_FAN_OUT` -**Message:** "Signal [name] changed with N subscribers — every one re-runs this flush. …" +**Message:** "[HUGE_FAN_OUT] Signal "selectedId" changed with 2000 subscribers — every one re-runs this flush. If many independent computations read the same value (for example every row of a list comparing against one selected id), prefer a per-key store or projection so only the items whose result flipped update." (the same text from either reporter; the attribution engine's finding adds `data.write`) -A committed change (a write, a memo's new value, an async landing) reached an unusually large number of live subscribers (first warning at 2000; re-warns once the count has grown by another 500). This is the signature of many independent computations reading the same value — for example, every row of a list comparing itself against one `selectedId` signal. Prefer a per-key store or a projection so only the items whose result actually flipped re-run. +A committed change (a write, a memo's new value, an async landing) reached an unusually large number of live subscribers. This is the signature of many independent computations asking keyed questions of one value — every row of a list comparing itself against one `selectedId` signal. Invert the subscription: keep the answer in a store used as a map keyed by id (`selected[row.id]` rather than `row.id === selectedId()`), so each consumer reads its own key and only the keys that flipped re-run; `createProjection` builds such a map when it is derived from other state. -Always on wherever the diagnostics channel exists (dev and observe tiers). The count is taken by the notification walk the change makes anyway, so it is the live subscriber list at that moment — disposed subscribers don't count — and the core keeps no per-node counter for it (a live edge count was a post-construction field on every node, and forked node shapes). Fires on the change, not on subscription: a fan-out that is never written costs nothing, and one that is re-runs every subscriber right then. - -Related: `WIDE_WRITE` (below) is the same finding from a much lower threshold, but only while the attribution engine is enabled; it hands over to `HUGE_FAN_OUT` at 2000, so one change never carries both. +One code, one threshold story, two reporters. **Always on** wherever the diagnostics channel exists (dev and observe tiers), the core fires from 2000 subscribers: the count is taken by the notification walk the change makes anyway, so it is the live subscriber list at that moment — disposed subscribers don't count — and the core keeps no per-node counter for it (a live edge count was a post-construction field on every node, and forked node shapes). While the **attribution engine** is enabled it reports the same finding from a much lower threshold — `enable({ fanOut })`, default 250 — on the root invalidations it stamps (a signal or store write, a `refresh()`, an async landing), counting the live subscriber list itself on the write, and adds `data.write: "write" | "refresh" | "async"` naming the invalidation; where the per-scope warnings below blame the _reader_, this is the fan-out actually happening, priced at the moment it happens. The engine hands over to the core at 2000, so one change never carries two findings. Either way the finding fires on the change, not on subscription (a fan-out that is never written costs nothing, and one that is re-runs every subscriber right then), once per node, re-warning only once the count has grown by another 500. Unchanged writes never fire it (the source equality gate commits nothing and notifies no one). `data.count` is the subscriber count. `fanOut: false` leaves only the always-on threshold; it is one of the six cost checks `checks: false` folds off. #### `HUGE_FAN_IN` @@ -444,15 +442,7 @@ Perf-kind warnings emitted by the **attribution engine** — they only fire whil All three thresholds are configurable (or disable-able) through `enable()` options. -#### `WIDE_WRITE` - -**Message:** "write to [name] reached N subscribers — every one re-runs this flush. …" - -A committed root invalidation — a signal or store write, a `refresh()`, or an async landing — reached a node with an unusually large number of live subscribers (default 250). Where the per-scope warnings above blame the _reader_, this one blames the _write_: it is the fan-out actually happening, priced at the moment it happens. The classic shape is many consumers asking keyed questions of one value (every row comparing against one selected id); the fix is inverting the subscription: keep the answer in a store used as a map keyed by id (`selected[row.id]` rather than `row.id === selectedId()`), so each consumer reads its own key and only the keys that flipped re-run; `createProjection` builds such a map when it is derived from other state. - -Attribution-engine only, like the trio above. Specced together with `HUGE_FAN_OUT` so the two never double-fire on one change: `WIDE_WRITE` covers the range from its threshold up to 2000 subscribers, once per node, re-warning only after the subscriber count doubles; from 2000 up the always-on `HUGE_FAN_OUT` takes over. Unchanged writes never fire it (the source equality gate commits nothing and notifies no one). The engine counts the live subscriber list on the write, so disposed subscribers don't count. - -Threshold configurable (or disable-able) via `enable({ wideWrites })`. +The write-side fan-out finding is `HUGE_FAN_OUT` (above), which the engine reports from its own lower threshold. #### `HOT_SCOPE_FANOUT` @@ -464,7 +454,7 @@ The per-cause aggregate of `HOT_SCOPE_RERUNS`. Hot-scope warnings blame the vict **Message:** "N sequential async flights — 'story' (120ms) → 'author' (80ms) — 200ms serialized: each began only after the previous resolved. …" -Attribution-engine only. An async flight (a promise or async iterable entering the system) formed a sequential chain behind an upstream flight. A chain link is asserted only on double proof: the flight's recompute was **caused** by the upstream's landing (graph causality — create runs inherit the enclosing recompute's causes, which covers boundary reveals and lazy first pulls), and the flight's **origin** post-dates the upstream's landing. Origin is the earliest provable start of the work: an `attribution.markFlight(promise, startedAt)` stamp (preloaders and request caches declaring their kickoff), first-seen object identity, else registration time — so preloaded work already in the air alongside its upstream is parallel and never chains. +Attribution-engine only, and the client graph's verdict — the server's `` boundary counterpart is its own code, `SSR_BOUNDARY_WATERFALL` (below), because its proof is a different fact. An async flight (a promise or async iterable entering the system) formed a sequential chain behind an upstream flight. A chain link is asserted only on double proof: the flight's recompute was **caused** by the upstream's landing (graph causality — create runs inherit the enclosing recompute's causes, which covers boundary reveals and lazy first pulls), and the flight's **origin** post-dates the upstream's landing. Origin is the earliest provable start of the work: an `attribution.markFlight(promise, startedAt)` stamp (preloaders and request caches declaring their kickoff), first-seen object identity, else registration time — so preloaded work already in the air alongside its upstream is parallel and never chains. The verdict is duration-gated (each link ≥ `waterfalls.minFlightMs`, default 50ms — a settled cache hit resolves fast and never warns). Depth-2 chains emit at `info` severity on the structured channel only: a dependent fetch is sometimes intrinsic, and an _unmarked_ external preload is indistinguishable from a real waterfall, so the console stays quiet. Depth-3+ escalates to a console `warn`. Once per node, re-warning only when the chain grows. Every graph-provable chain — warned or not — is queryable via `attribution.history("waterfall")`. @@ -605,17 +595,18 @@ Finding (`warn`, observe + dev; no `ownerPath` — a stream event). The consumer Finding (`error`, observe + dev) plus the existing behavior: the dev build **throws** the same message (the throw is the console face; the record is not printed twice), other tiers log it and drop the write. Application code wrote a response header after the head had been flushed — the value was lost, and the response looked fine, which is why a production consumer wants this one counted. `data.method`, `data.name`; `ownerPath` names the component when the write came from inside a late-rendering one. Move the write before the first flush, or before the handler returns. -#### `SERVER_FN_ERROR_SANITIZED` +#### `SERVER_ERROR_SANITIZED` -**Message:** "[SERVER_FN_ERROR_SANITIZED] Server function error replaced with a generic Error before serialization: TypeError: …" +**Messages:** -Finding (`error`, observe + dev). A server function threw and the non-dev wire replaced the error with the generic message (RFC 10's sanitization). The client sees the replacement; this record carries the original in `data.error`. Beside the invocation channel's `outcome: "error"` it is the one place the real failure surfaces in production. A value branded with `markSafeError` passes through and is not reported. +- "[SERVER_ERROR_SANITIZED] Server function error replaced with a generic Error before serialization: TypeError: …" (`data.source: "server-function"`) +- "[SERVER_ERROR_SANITIZED] Render error replaced before reaching the client: TypeError: …" (`data.source: "ssr"`) -#### `SSR_ERROR_SANITIZED` +Finding (observe + dev; channel only). One policy — the wire got the generic `Error` (`"Internal Server Error"`), the observer sees the real failure — on two roads, told apart by `data.source`; `data.error` is the original on both, `data.wire` what replaced it (the generic `Error`, or what the server error hook returned; [RFC 12](12-ssr-http.md#the-server-error-hook-configureservererrors--onservererror)). A value branded with `markSafeError` passes through on either road and is not reported. The dev build keeps full fidelity and records nothing. -**Message:** "[SSR_ERROR_SANITIZED] Render error replaced before reaching the client: TypeError: …" +`"server-function"` (`error`): a server function threw and the non-dev wire replaced the error with the generic message (RFC 10's sanitization). The client sees the replacement; beside the invocation channel's `outcome: "error"` this record is the one place the real failure surfaces in production — which is why this road is `error` where the other is advisory. -Finding (`info`, observe + dev; channel only). A render failure was about to reach the client through one of SSR's roads — the record an `` serializes so the client hydrates the same fallback, a rejected async source serialized into the stream, a `` fragment's `_fr` rejection, a frame stream's error chunk (the fragment's, a live hole's, the root's) — and the non-dev wire replaced it with the generic `Error` (`"Internal Server Error"`), the same policy the server-function wire applies (`SERVER_FN_ERROR_SANITIZED`; a `"use server"` function called in-process during SSR never touches that wire, so before this the page load leaked what the RPC withheld — [#3468](https://github.com/solidjs/solid/issues/3468)). The boundary sanitizes _before_ rendering its fallback and serializes the same replacement, so fallback markup and record agree on hydration. Advisory because the failure itself is the `SSR_RENDER_ERROR_CONTAINED` finding's, which carries the original; this is the record of what the wire carried instead — `data.error` the original, `data.wire` the replacement (the generic `Error`, or what the server error hook returned; [RFC 12](12-ssr-http.md#the-server-error-hook-configureservererrors--onservererror)) — once per original however many roads it took. The hook is the prod-tier seam for the same facts; these findings sit above it, recording whatever the wire actually carried. A value branded with `markSafeError` passes through and is not reported; an Error reached as a _value_ — never thrown — is data and passes as the author wrote it (#3113's ruling). The dev build keeps full fidelity. +`"ssr"` (`info`): a render failure was about to reach the client through one of SSR's roads — the record an `` serializes so the client hydrates the same fallback, a rejected async source serialized into the stream, a `` fragment's `_fr` rejection, a frame stream's error chunk (the fragment's, a live hole's, the root's) — and the non-dev wire replaced it, the same policy the server-function wire applies (a `"use server"` function called in-process during SSR never touches that wire, so before this the page load leaked what the RPC withheld — [#3468](https://github.com/solidjs/solid/issues/3468)). The boundary sanitizes _before_ rendering its fallback and serializes the same replacement, so fallback markup and record agree on hydration. Advisory because the failure itself is the `SSR_RENDER_ERROR_CONTAINED` finding's, which carries the original; this is the record of what the wire carried instead, once per original however many roads it took. The hook is the prod-tier seam for the same facts; these findings sit above it, recording whatever the wire actually carried. An Error reached as a _value_ — never thrown — is data and passes as the author wrote it (#3113's ruling). #### `FRAME_MARKER_CORRUPTED` @@ -645,11 +636,11 @@ The client's code, `data.side: "server"`, and a hard **error** on the server in Check (`warn`, dev only). `renderToString` has no stream to coordinate reveal order on. `data.order`, `data.collapsed`. -#### `ASYNC_WATERFALL` (server) +#### `SSR_BOUNDARY_WATERFALL` -**Message:** "[ASYNC_WATERFALL] 2 sequential async flights in a boundary — 84ms over 3 render passes: each read could start only after the previous one answered. If a later read doesn't need the earlier answer, derive both from the same inputs so they start together; if the dependency is intrinsic, preload the dependent data or join the requests." +**Message:** "[SSR_BOUNDARY_WATERFALL] A boundary took 3 render passes — 2 sequential async waits, 84ms end to end: each read could start only after the previous one answered. If a later read doesn't need the earlier answer, derive both from the same inputs so they start together; if the dependency is intrinsic, preload the dependent data or join the requests." -The client's code, `data.side: "server"`, `kind: "perf"`; a check (dev only) read off the boundary's facts — the same ones the `"boundary"` record carries (below), no listener needed. A `` boundary is rendered in passes: discovery, then one per wait, each pass a read that could only start once the previous pass's async answered — so `passes - 1` is the number of sequential flights, and the proof is exact where the client's is graph-inferred. Same thresholds: two flights (`passes: 3`) are `info`, structured-channel only, since a dependent fetch is sometimes intrinsic; three or more earn the console `warn`. Once per boundary per render. `data.boundary` is the boundary's hydration id (the record's `id`), `data.passes`, `data.sequentialMs` (discovery → settle). Located by component. +Check (`kind: "ssr"`, dev only) read off the boundary's facts — the same ones the `"boundary"` record carries (below), no listener needed. A `` boundary is rendered in passes: discovery, then one per wait, each pass a read that could only start once the previous pass's async answered — so `passes - 1` is the number of sequential waits, and the proof is exact: the pass structure IS the chain. The client's `ASYNC_WATERFALL` states a different fact (a graph-inferred, origin-proven chain of flights, duration-gated, with `markFlight` as its escape hatch), so it keeps its own code; a budget keyed by code tells the two apart. Same thresholds: two waits (`passes: 3`) are `info`, structured-channel only, since a dependent fetch is sometimes intrinsic; three or more earn the console `warn`. Once per boundary per render. `data.boundary` is the boundary's hydration id (the record's `id`), `data.passes`, `data.sequentialMs` (discovery → settle). Located by component. #### `SSR_CLIENT_CONTENT_MASKED` @@ -778,7 +769,7 @@ const off = OBSERVE.records.subscribe("boundary", (event, live) => { A boundary whose content rendered on its first pass emits nothing — there was no wait to attribute, the same rule as the client's `hold` records. For one that waited: `id` is the boundary's hydration id, the id the `SSR_RENDER_ERROR_CONTAINED` finding names in `data.boundary`, so a record and a finding pair by it; `at` is `performance.now()` at discovery; `durationMs` runs discovery → settle — to the content being complete, or to the decision that the server will not produce it; `passes` counts render passes over the content (the discovery pass plus one per wait — `2` is one round of async, more is a sequential chain, a read that depended on the answer to the previous one); `streamed` says whether the outcome reached the client after the shell had flushed (the user saw the fallback, then the swap) or in time to inline into it. `outcome` is how it ended: `"settled"` — rendered on the server and swapped in; `"fallback"` — the renderer had no stream to settle into (`renderToString`), the fallback shipped final and the client renders the content; `"client"` — the content is client-only (`ssrSource: "client"`); `"error"` — the content threw, `live.error` is the value as thrown, and the paired finding says where it went. Under a `` group the record names the group and waits for the group's reveal, so `heldMs` is real: how long the finished content sat behind its siblings (`order="together"`, a sequential tail) — `0` for every boundary whose swap was issued as it settled. `ownerPath` locates the boundary by component, the same labels the diagnostics carry. The cost is paid only with a listener installed (or in dev, where the checks below read the same facts): a boundary with none takes no clock readings. -Two dev checks are derived from these facts, so the console and a test's `expectNoDiagnostics` see what an agent would otherwise have to read off the record: `ASYNC_WATERFALL` (server) when `passes - 1` sequential flights reach two, and `SSR_CLIENT_CONTENT_MASKED` when a client-only outcome surfaced only after a wait. Both key by `data.boundary` — the record's `id`. +Two dev checks are derived from these facts, so the console and a test's `expectNoDiagnostics` see what an agent would otherwise have to read off the record: `SSR_BOUNDARY_WATERFALL` when `passes - 1` sequential waits reach two, and `SSR_CLIENT_CONTENT_MASKED` when a client-only outcome surfaced only after a wait. Both key by `data.boundary` — the record's `id`. The **`"invocation"` record** (from `@solidjs/web`, server) is one server-function execution: @@ -886,15 +877,14 @@ The runtime derives a request's trace itself in every tier — the W3C `tracepar | `NO_OWNER_BOUNDARY` | warn | lifecycle | Boundary created without owner | | `RUN_WITH_DISPOSED_OWNER` | warn | owner | `runWithOwner` with disposed owner | | `FLUSH_IN_EFFECT_CALLBACK` | warn | lifecycle | `flush()` from an effect callback (no-op; the drain is already running) | -| `HUGE_FAN_OUT` | warn | graph | One change reached 2000 live subscribers (always on) | +| `HUGE_FAN_OUT` | warn | graph | One change reached 2000 live subscribers (always on), or 250 — `fanOut` — on a stamped root write (attribution enabled) | | `HUGE_FAN_IN` | warn | graph | One recompute tracked 2000 sources (always on) | | `GRAPH_GROWTH` | warn | perf | The live owner count at the same route's settle climbed on 3 consecutive visits to 1.25× — something each visit leaves behind (attribution enabled) | | `HOT_SCOPE_RERUNS` | warn | perf | 120+ re-runs of one scope in 1s (attribution enabled) | | `HOT_SCOPE_FANOUT` | warn | perf | 5+/50+/500+ scopes hot from one root cause (attribution enabled) | | `HOT_SCOPE_TIME` | warn | perf | 8ms+ self-time in one scope in 1s (attribution enabled) | | `WIDE_SCOPE_DEPS` | warn | perf | Scope subscribed to 30+ sources (attribution enabled) | -| `WIDE_WRITE` | warn | perf | Committed write reached 250+ subscribers (attribution enabled) | -| `ASYNC_WATERFALL` | info/warn | perf | 2+/3+ sequential async flights: origin-proven (attribution enabled), or a `` boundary's passes (server, dev) | +| `ASYNC_WATERFALL` | info/warn | perf | 2+/3+ sequential async flights, origin-proven (attribution enabled) | | `UNSTABLE_MEMO_OUTPUT` | warn | perf | Memo returned a new-but-equivalent container 4+ runs running (attribution enabled) | | `WASTED_RECOMPUTE` | warn | perf | 80%+ of a scope's 5+ runs in a second produced an unchanged value for 2ms+ of compute — inputs change, result does not (attribution enabled) | | `EFFECT_WRITES_OWN_SOURCE` | info/warn | perf | Effect's write provably feeds back into its own inputs; `info` for multi-effect rings (attribution enabled) | @@ -912,10 +902,11 @@ The runtime derives a request's trace itself in every tier — the W3C `tracepar | `SSR_SUBTREE_ABANDONED` | warn | ssr | A failed fragment's pending descendants were discarded (observe + dev) | | `SSR_STREAM_ABANDONED` | warn | ssr | Response stream cancelled or sink failed with fragments pending (observe + dev) | | `LATE_HEADER_WRITE` | error | head | Response header written after the head was sent; dropped (observe + dev; dev throws) | -| `SERVER_FN_ERROR_SANITIZED` | error | ssr | Server-function error replaced with the generic Error on the wire; `data.error` is the original (observe + dev) | +| `SERVER_ERROR_SANITIZED` | error/info| ssr | Error replaced with the generic Error on the wire — `data.source` `"server-function"` (`error`) or `"ssr"` (`info`); `data.error` is the original (observe + dev) | | `FRAME_MARKER_CORRUPTED` | error | ssr | Frame slot range missing its end marker on the client — nesting or an HTML rewriter (observe + dev) | | `SERVER_WRITE` | warn | write | Signal/store/optimistic setter ran during a server render; inert, will throw (dev; once per category) | | `REVEAL_IN_RENDER_TO_STRING` | warn | ssr | Nested `` with collapsed/together under `renderToString` (dev) | +| `SSR_BOUNDARY_WATERFALL` | info/warn | ssr | A `` boundary needed 3+/4+ render passes — 2+/3+ sequential async waits (dev) | | `SSR_CLIENT_CONTENT_MASKED` | warn | ssr | Client-only content in a `` surfaced only after a server wait; the server's work was discarded (dev) | | `LAZY_ASSET_UNMAPPED` | warn | ssr | `lazy()` component's client assets could not be resolved for the page (dev) | | `PRELOAD_DESCRIPTOR_INVALID` | warn | head | `registerAsset("preload")` descriptor broke a field rule; link dropped or field ignored (dev) | @@ -949,11 +940,11 @@ const release = attribution.enable({ hotTime: { budgetMs: 8, windowMs: 1000 }, // or false wideDeps: 30, // or false unstableMemos: 4, // or false - wideWrites: 250, // or false + fanOut: 250, // or false (HUGE_FAN_OUT threshold while enabled) waterfalls: { minFlightMs: 50 }, // or false holds: { infoMs: 100, warnMs: 200 }, // or false (disables hold tracking) longHolds: { infoMs: 500, warnMs: 1000 }, // or false - checks: true // false: records only — none of the five cost checks above + checks: true // false: records only — none of the six cost checks above }); // The ring buffers, by type (readonly, oldest first, `historyLimit` deep): @@ -1049,7 +1040,7 @@ createRoot(() => { **Excluding the observer.** `OBSERVE.exclude(owner)` marks an owner subtree as the observer's own: diagnostics whose subject sits under it are built (a throwing site still throws) but never delivered or printed, and the attribution engine records no run for its computations, charges none of them to an interaction, counts no write to its signals or stores toward an interaction, and does not spend a once-per-key slot (`IMMUTABLE_UPDATE_IN_STORE`'s per-path memory) on them. An interaction whose writes all went to excluded subjects, with none of the app's work run — a click on the observer's own panel — is not recorded at all. Mark the root as it is created (a store's nodes take the owner the store was created under, recorded only once the engine is enabled — enable before creating the panel's stores). The signals and stores created under it are excluded subjects wherever their writes come from — a click handler, an adapter callback — so writes need no `runWithOwner`, and must not use one: a write under an owner is a write in an owned scope (`REACTIVE_WRITE_IN_OWNED_SCOPE`). `OBSERVE.isExcluded(subject)` answers the question for any owner or node. -**Values in records — the PII surface.** Records name things (owner paths, `name` options, store paths, route patterns, function ids) and are otherwise numbers, kinds and outcomes; a handful of fields carry _user data_, and an exporter that leaves the process owns scrubbing them (vendors already have the control surface — `beforeSend`, `sendDefaultPii` — and the runtime keeps producing them because they are what makes dev output readable). The complete list: `ChangeRecord.prev`/`value` and `HeldWrite.prev`/`value` — previews of the written values (`preview()`: strings quoted and cut at 40 characters, numbers/booleans verbatim, everything else a type tag such as `Array(12)` or `[Object]`), so the string case is the one to drop or hash unless opted in; `ChangeOrigin.target` (and `InteractionRef.target`) — the element hit, `tag#id "text"` with up to 30 characters of `textContent` for anything that is not an `input`/`textarea`/`select`, so a label but also whatever a `` said; `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; `DiagnosticEvent.message` and `data` for the responsiveness findings (`SILENT_HOLD`, `LONG_HOLD`) — the verdict sentence names the interaction (`click on button#next "Next →"`) and the navigation it was under (concrete `to`/`from`/`params`), and `data.interaction.target` / `data.navigation` carry the same fields structured; no finding quotes a value preview. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SSR_ERROR_SANITIZED`, `SERVER_FN_ERROR_SANITIZED`) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"invocation"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. +**Values in records — the PII surface.** Records name things (owner paths, `name` options, store paths, route patterns, function ids) and are otherwise numbers, kinds and outcomes; a handful of fields carry _user data_, and an exporter that leaves the process owns scrubbing them (vendors already have the control surface — `beforeSend`, `sendDefaultPii` — and the runtime keeps producing them because they are what makes dev output readable). The complete list: `ChangeRecord.prev`/`value` and `HeldWrite.prev`/`value` — previews of the written values (`preview()`: strings quoted and cut at 40 characters, numbers/booleans verbatim, everything else a type tag such as `Array(12)` or `[Object]`), so the string case is the one to drop or hash unless opted in; `ChangeOrigin.target` (and `InteractionRef.target`) — the element hit, `tag#id "text"` with up to 30 characters of `textContent` for anything that is not an `input`/`textarea`/`select`, so a label but also whatever a `` said; `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; `DiagnosticEvent.message` and `data` for the responsiveness findings (`SILENT_HOLD`, `LONG_HOLD`) — the verdict sentence names the interaction (`click on button#next "Next →"`) and the navigation it was under (concrete `to`/`from`/`params`), and `data.interaction.target` / `data.navigation` carry the same fields structured; no finding quotes a value preview. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SERVER_ERROR_SANITIZED` on either road) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"invocation"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. `costs()` aggregates since `enable()`: `scopes` ranked by self-time with `wastedMs` (time in runs whose value didn't change — the equality cutoff absorbed them), and `writes` ranked by the total downstream re-run time each root write caused. Overlay work (optimistic-lane and held runs — `phase: "optimistic" | "held"`) is accounted separately as `overlayMs` and never blamed as waste. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index e1ade0065..513644519 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -113,7 +113,7 @@ Two ways to send intentional error content in production: Framework error hooks compose the same way: a `wrapInvocation`/`transformResult` override that maps a thrown error expresses intent by throwing an envelope or branding its replacement with `markSafeError` — core never second-guesses a branded value, and never trusts an unbranded one. -The same policy covers the SSR roads a failure takes to the client — an `` record, a rejected async source in the stream, a fragment's rejection, a frame's error chunks — so a `"use server"` function called in-process during a render (which never touches this handler) cannot ship on the page what the RPC wire withholds; see [RFC 12](12-ssr-http.md#what-a-render-failure-looks-like-from-the-client) and `SSR_ERROR_SANITIZED` in [RFC 08](08-dev-diagnostics.md#ssr_error_sanitized). `markSafeError` is the one brand on both. +The same policy covers the SSR roads a failure takes to the client — an `` record, a rejected async source in the stream, a fragment's rejection, a frame's error chunks — so a `"use server"` function called in-process during a render (which never touches this handler) cannot ship on the page what the RPC wire withholds; see [RFC 12](12-ssr-http.md#what-a-render-failure-looks-like-from-the-client) and `SERVER_ERROR_SANITIZED` in [RFC 08](08-dev-diagnostics.md#server_error_sanitized). `markSafeError` is the one brand on both. **The server error hook** ([RFC 12](12-ssr-http.md#the-server-error-hook-configureservererrors--onservererror)) hears every failure on this wire before the policy applies — `kind: "server-function"`, `handling: "thrown"` for the body's throw, `"channel"` for a failure escaping through the result graph, `functionId` and `direct` (an in-process call during SSR) named — once per error object; its return, when given, is what the client receives. Ambient through `configureServerErrors`, per request through `handleServerFunctionRequest(request, { onError })` (entry-only, like `wrapInvocation`: a direct call the body makes reports through the ambient hook). A `wrapInvocation` that maps errors keeps working; the hook is where a _reporting_ integration (an error monitor) plugs in without owning invocation policy. diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index 92546aa0c..968287a28 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -142,7 +142,7 @@ A render failure reaches the client on several roads: the error an `` c The boundary sanitizes _before_ rendering its fallback, and serializes the same replacement: the fallback is rendered on the server with the error and hydrates against the record, so the two must agree. A fallback that prints `err().message` therefore shows the generic message in production, as it would for a server-function failure. `markSafeError` is the escape hatch on both wires — a branded error passes through with its own properties. An Error reached as a _value_ (never thrown — a form's field errors, say) is data and passes as written. -The dev/prod line is the build variant: the `development` export condition's server artifacts keep full fidelity; the production and observe artifacts sanitize. The observe tier records each replacement once as `SSR_ERROR_SANITIZED` (advisory; `data.error` the original, `data.wire` what replaced it), beside the `SSR_RENDER_ERROR_CONTAINED` finding that carries the failure itself — the server keeps the truth, the wire gets the generic. +The dev/prod line is the build variant: the `development` export condition's server artifacts keep full fidelity; the production and observe artifacts sanitize. The observe tier records each replacement once as `SERVER_ERROR_SANITIZED` with `data.source: "ssr"` (advisory; `data.error` the original, `data.wire` what replaced it), beside the `SSR_RENDER_ERROR_CONTAINED` finding that carries the failure itself — the server keeps the truth, the wire gets the generic. #### The server error hook: `configureServerErrors` / `onError` diff --git a/packages/babel-plugin/README.md b/packages/babel-plugin/README.md index e1cf3f1ad..4ed1ec2da 100644 --- a/packages/babel-plugin/README.md +++ b/packages/babel-plugin/README.md @@ -130,14 +130,14 @@ Development output. With `hydratable`, emits the hydration walk validation helpe ### sourceNames - Type: `boolean | { components?: boolean; bindings?: boolean }` -- Default: `false` +- Default: follows `dev` — every kind on when `dev: true`, off otherwise -Names as written in source, carried into the output so the dev and observe runtimes can label the reactive graph — in diagnostic `ownerPath`s, attribution chains, and the Performance panel tracks — even after a minifier renames everything. `true` turns on every kind; the object form picks. The production runtimes ignore the names, and output is byte-identical with the option off. +Names as written in source, carried into the output so the dev and observe runtimes can label the reactive graph — in diagnostic `ownerPath`s, attribution chains, and the Performance panel tracks — even after a minifier renames everything. Unset, the option follows `dev`; `true`/`false` sets every kind (`sourceNames: false` opts a dev build out); the object form picks, and each kind it leaves unspecified follows `dev`. The production runtimes ignore the names, and production output (`dev: false`) is byte-identical whether the option is set or not. `@solidjs/compiler`'s `transform()` takes the same option with the same shape and defaults. - `components`: emit the tag as written as a third `createComponent` argument — `` compiles to `createComponent(Home, props, "Home")`, `` to `"Ui.Button"` — so each component's owner reads `` even when a `lazy()`/HMR wrapper hides the function. Applies to DOM and SSR output; for SSR the compiler keeps the `createComponent` call it otherwise inlines to `Comp(props)`, so the server runtime labels the owner the same way (prod SSR output, without the option, is unchanged). Universal and dynamic output are unaffected. - `bindings`: every compiled binding effect is named by what it writes, as a trailing options argument the dev and observe runtimes read and production ignores. An attribute effect gets `.` as written — `` compiles to `effect(() => label(), v => …, { name: "span.textContent" })`, `class:active` to `div.class:active`, a `style={{ color: c() }}` property to `div.style:color` — and a template's merged effect lists all of its bindings (`"button.class, span.textContent"`). A hole's insert is named for the parent it fills: `
{count()}
` compiles to `insert(el, count, undefined, undefined, { name: "div.children" })`; a static child (a component call, a literal) creates no effect and gets no name. A spread passes the tag as its trailing argument (`spread(el, props, false, undefined, "div")`), and the runtime labels its attribute effect `div.spread` and its children insert `div.children`. DOM output only. -`@solidjs/vite-plugin` turns this on for its dev and `observe` postures. +These are the JSX-level kinds — what a JSX compiler can see. Naming the primitives themselves (`createSignal(0, { name: "count" })`, `createCounter.value` inside a composed primitive) is not a JSX-transform feature and this plugin does not do it: it is `@solidjs/compiler`'s standalone `transformSourceNames` pass, which the build tool runs on every module — `.ts`, `.js`, and JSX files alike — independently of which JSX compiler handles the file. `@solidjs/vite-plugin` (its `solid: { sourceNames }` option, solid-vite-plugin #371) wires both: the JSX-level kinds through this option and primitive names through the standalone pass, for its dev and `observe` postures. ### delegateEvents diff --git a/packages/babel-plugin/src/config.ts b/packages/babel-plugin/src/config.ts index e41de15a1..441a81e7a 100644 --- a/packages/babel-plugin/src/config.ts +++ b/packages/babel-plugin/src/config.ts @@ -36,9 +36,14 @@ export interface PluginConfig { * binding effect named by what it writes — `span.textContent`, * `div.class:active`, `div.style:color`, a hole `div.children`, a spread * `div.spread` — as an options argument on `effect`/`insert`/`spread`; - * DOM output only. The production runtimes ignore the names. `true` - * enables every kind; an object picks. */ - sourceNames: boolean | SourceNamesConfig; + * DOM output only. The production runtimes ignore the names. Defaults to + * `dev`: unset, every kind is on in dev and off otherwise; `true`/`false` + * sets every kind; an object picks, and each kind it leaves unspecified + * follows `dev`. Primitive names (`createSignal(0, { name: "count" })`) + * are not a kind here — they come from `@solidjs/compiler`'s standalone + * `transformSourceNames` pass, which the build tool runs on every module + * independently of the JSX compiler. */ + sourceNames?: boolean | SourceNamesConfig; delegateEvents: boolean; delegatedEvents: string[]; builtIns: string[]; @@ -71,7 +76,8 @@ const config: PluginConfig = { generate: "dom", hydratable: false, dev: false, - sourceNames: false, + // `sourceNames` has no default of its own: unset, it follows `dev` + // (see `sourceNames()`). delegateEvents: true, delegatedEvents: [], builtIns: [ @@ -102,11 +108,15 @@ const config: PluginConfig = { hoistProps: true }; -/** `sourceNames` resolved to its per-kind flags (`true` → every kind on). */ +/** + * `sourceNames` resolved to its per-kind flags. Unset, every kind is `dev`; + * a boolean sets every kind; in the object form each kind left unspecified + * is `dev`. `@solidjs/compiler` resolves its option the same way. + */ export function sourceNames(config: PluginConfig): Required { - const value = config.sourceNames; + const value = config.sourceNames ?? config.dev; if (typeof value === "boolean") return { components: value, bindings: value }; - return { components: value?.components ?? false, bindings: value?.bindings ?? false }; + return { components: value.components ?? config.dev, bindings: value.bindings ?? config.dev }; } /** diff --git a/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js b/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js index 2d21fc00b..97ec3341a 100644 --- a/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_dev_fixtures__/walkValidation/output.js @@ -13,7 +13,12 @@ var _el$ = _$getNextElement(_tmpl$), _el$2 = _$getFirstChild(_el$, "span"); _$insert( _el$2, - _$scope(() => name()) + _$scope(() => name()), + undefined, + undefined, + { + name: "span.children" + } ); const singleChild = _el$; var _el$3 = _$getNextElement(_tmpl$2), @@ -21,7 +26,12 @@ var _el$3 = _$getNextElement(_tmpl$2), _el$5 = _$getNextSibling(_el$4, "main"); _$insert( _el$5, - _$scope(() => name()) + _$scope(() => name()), + undefined, + undefined, + { + name: "main.children" + } ); const siblingElements = _el$3; var _el$6 = _$getNextElement(_tmpl$3), @@ -29,7 +39,12 @@ var _el$6 = _$getNextElement(_tmpl$3), _el$8 = _$getNextSibling(_el$7, "b"); _$insert( _el$8, - _$scope(() => name()) + _$scope(() => name()), + undefined, + undefined, + { + name: "b.children" + } ); const mixedTextAndElements = _el$6; var _el$9 = _$getNextElement(_tmpl$4), @@ -37,6 +52,11 @@ var _el$9 = _$getNextElement(_tmpl$4), _el$1 = _$getFirstChild(_el$0, "li"); _$insert( _el$1, - _$scope(() => name()) + _$scope(() => name()), + undefined, + undefined, + { + name: "li.children" + } ); const nestedWalk = _el$9; diff --git a/packages/babel-plugin/test/dom-source-names.spec.js b/packages/babel-plugin/test/dom-source-names.spec.js index 4b29afbac..ff0375d5e 100644 --- a/packages/babel-plugin/test/dom-source-names.spec.js +++ b/packages/babel-plugin/test/dom-source-names.spec.js @@ -16,3 +16,46 @@ runFixtures({ title: "Convert JSX (sourceNames)", fixtures: path.join(__dirname, "__dom_source_names_fixtures__") }); + +// `sourceNames` follows `dev` when unset; `@solidjs/compiler`'s +// `transform.test.js` asserts the same on the same source. +describe("sourceNames defaults", () => { + const babel = require("@babel/core"); + const code = "const view =
;"; + const label = '_$createComponent(Home, {}, "Home")'; + const binding = 'name: "div.class"'; + const compile = opts => + babel.transformSync(code, { + configFile: false, + babelrc: false, + filename: "input.jsx", + plugins: [[plugin, { moduleName: "r-dom", ...opts }]] + }).code; + + test("unset follows dev: every kind on in dev, off otherwise", () => { + const dev = compile({ dev: true }); + expect(dev).toContain(label); + expect(dev).toContain(binding); + const prod = compile({ dev: false }); + expect(prod).not.toContain('"Home"'); + expect(prod).not.toContain("name:"); + expect(prod).toBe(compile({})); + }); + + test("production output is byte-identical with the option set or unset", () => { + expect(compile({ dev: false })).toBe(compile({ dev: false, sourceNames: false })); + expect(compile({ dev: false })).toBe(compile({ dev: false, sourceNames: {} })); + }); + + test("`false` opts out in dev; the object form's unspecified kinds follow dev", () => { + const off = compile({ dev: true, sourceNames: false }); + expect(off).not.toContain('"Home"'); + expect(off).not.toContain("name:"); + const picked = compile({ dev: true, sourceNames: { components: false } }); + expect(picked).not.toContain('"Home"'); + expect(picked).toContain(binding); + const prodPicked = compile({ dev: false, sourceNames: { components: true } }); + expect(prodPicked).toContain(label); + expect(prodPicked).not.toContain(binding); + }); +}); diff --git a/packages/babel-plugin/test/ssr-props.spec.js b/packages/babel-plugin/test/ssr-props.spec.js index 1385517ea..f625cee11 100644 --- a/packages/babel-plugin/test/ssr-props.spec.js +++ b/packages/babel-plugin/test/ssr-props.spec.js @@ -24,7 +24,10 @@ const runtime = { escape: v => v, mergeProps: (...sources) => Object.assign({}, ...sources), memo: fn => fn, - applyRef: (r, el) => r(el) + applyRef: (r, el) => r(el), + // Dev output keeps the `createComponent` call (with the source name) the + // production SSR output inlines to `Comp(props)`. + createComponent: (Comp, props) => Comp(props) }; /** Runs the module body with the imports bound to `runtime`; `__result` is what the snippet exposes. */ diff --git a/packages/compiler/README.md b/packages/compiler/README.md index 3c4b8af2e..ca3361d65 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -113,7 +113,7 @@ Pass `sourceMap: true` to receive a JSON source map string in `result.map`. For - `generate`: `"dom"`, `"ssr"`, `"universal"`, or `"dynamic"` (default `"dom"`) - `hydratable` - `dev` -- `sourceNames` (`boolean | { components?: boolean; bindings?: boolean }`): names as written in source, carried into output so dev/observe runtimes can label the reactive graph after minification; `true` for every kind. `components` emits the source tag name as `createComponent`'s third argument (`createComponent(Home, props, "Home")`) — DOM and SSR output (SSR keeps the `createComponent` call it otherwise inlines to `Comp(props)`); not universal or dynamic. `bindings` names every compiled binding effect by what it writes — `effect(…, { name: "span.textContent" })`, a hole `insert(el, v, undefined, undefined, { name: "div.children" })`, a spread `spread(el, props, false, undefined, "div")` (labelled `div.spread` / `div.children` by the runtime) — DOM output only. The production runtimes ignore the names. Naming the primitives themselves is the separate `transformSourceNames` pass below, since it applies to plain `.ts`/`.js` modules too +- `sourceNames` (`boolean | { components?: boolean; bindings?: boolean }`, default follows `dev`): names as written in source, carried into output so dev/observe runtimes can label the reactive graph after minification. Unset, every kind is on when `dev: true` and off otherwise; `true`/`false` sets every kind (`sourceNames: false` opts a dev build out); the object form picks, and each kind it leaves unspecified follows `dev`. Production output (`dev: false`) is byte-identical with the option set or unset. Same shape and defaults as `@solidjs/babel-plugin`'s option. `components` emits the source tag name as `createComponent`'s third argument (`createComponent(Home, props, "Home")`) — DOM and SSR output (SSR keeps the `createComponent` call it otherwise inlines to `Comp(props)`); not universal or dynamic. `bindings` names every compiled binding effect by what it writes — `effect(…, { name: "span.textContent" })`, a hole `insert(el, v, undefined, undefined, { name: "div.children" })`, a spread `spread(el, props, false, undefined, "div")` (labelled `div.spread` / `div.children` by the runtime) — DOM output only. The production runtimes ignore the names. These are the JSX-level kinds only: naming the primitives themselves is the separate `transformSourceNames` pass below, which the build tool runs on every module independently of the JSX compiler - `sourceMap` - `contextToCustomElements` (default `true`) - `delegateEvents` @@ -162,7 +162,7 @@ The runtime module defaults to `@solidjs/web/server-functions`. Function IDs are ### Source names for primitives -`transformSourceNames(code, { filename?, sourceMap? })` is the pass behind `@solidjs/vite-plugin`'s `sourceNames.primitives` option: it names reactive primitives after the identifier they are declared as, so the dev and observe runtimes label graph nodes `count` / `doubled` / `todos.title` instead of `signal` / `computed` / `store.title`. It is plain JavaScript in and out (JSX passes through untouched), which is why it is its own pass rather than a `transform()` option — primitives live in `.ts`/`.js` modules as much as in components. `@solidjs/vite-plugin` runs it ahead of the JSX transform for the dev and `observe` postures. +`transformSourceNames(code, { filename?, sourceMap? })` is the pass behind `@solidjs/vite-plugin`'s `sourceNames.primitives` option: it names reactive primitives after the identifier they are declared as, so the dev and observe runtimes label graph nodes `count` / `doubled` / `todos.title` instead of `signal` / `computed` / `store.title`. It is plain JavaScript in and out (JSX passes through untouched), which is why it is its own pass rather than a `transform()` option — primitives live in `.ts`/`.js` modules as much as in components. This standalone pass is the single owner of primitive naming: the build tool runs it on every module — `.ts`, `.js`, and JSX files alike — independently of which JSX compiler (this one or `@solidjs/babel-plugin`) handles the file's JSX, so there is no Babel counterpart and `transform()`'s `sourceNames` covers only the JSX-level kinds. `@solidjs/vite-plugin` (solid-vite-plugin #371, `solid: { sourceNames }`) runs it ahead of the JSX transform for the dev and `observe` postures. ```js const [count, setCount] = createSignal(0); // createSignal(0, { name: "count" }) diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js index 7ed05a8c4..ff78c2dd3 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable-dev/walkValidation/output.js @@ -13,26 +13,26 @@ var _el$ = _$getNextElement(_tmpl$); var _el$2 = _$getFirstChild(_el$, "span"); _$insert(_el$2, _$scope(() => { return name(); -})); +}), undefined, undefined, { name: "span.children" }); const singleChild = _el$; var _el$3 = _$getNextElement(_tmpl$2); var _el$4 = _$getFirstChild(_el$3, "header"); var _el$5 = _$getNextSibling(_el$4, "main"); _$insert(_el$5, _$scope(() => { return name(); -})); +}), undefined, undefined, { name: "main.children" }); const siblingElements = _el$3; var _el$6 = _$getNextElement(_tmpl$3); var _el$7 = _el$6.firstChild; var _el$8 = _$getNextSibling(_el$7, "b"); _$insert(_el$8, _$scope(() => { return name(); -})); +}), undefined, undefined, { name: "b.children" }); const mixedTextAndElements = _el$6; var _el$9 = _$getNextElement(_tmpl$4); var _el$10 = _$getFirstChild(_el$9, "ul"); var _el$11 = _$getFirstChild(_el$10, "li"); _$insert(_el$11, _$scope(() => { return name(); -})); +}), undefined, undefined, { name: "li.children" }); const nestedWalk = _el$9; diff --git a/packages/compiler/__tests__/transform.test.js b/packages/compiler/__tests__/transform.test.js index ba349761d..86077f00e 100644 --- a/packages/compiler/__tests__/transform.test.js +++ b/packages/compiler/__tests__/transform.test.js @@ -551,6 +551,36 @@ describe("@solidjs/compiler transform", () => { ); }); + // Same source and expectations as babel-plugin/test/dom-source-names.spec.js + // "sourceNames defaults". + it("sourceNames follows dev when unset; production output is byte-identical", () => { + const code = "const view =
;"; + const opts = { filename: "input.jsx", moduleName: "r-dom" }; + const label = '_$createComponent(Home, {}, "Home")'; + const binding = 'name: "div.class"'; + + const dev = transform(code, { ...opts, dev: true }).code; + expect(dev).toContain(label); + expect(dev).toContain(binding); + + const prod = transform(code, { ...opts, dev: false }).code; + expect(prod).not.toContain('"Home"'); + expect(prod).not.toContain("name:"); + expect(prod).toBe(transform(code, opts).code); + expect(prod).toBe(transform(code, { ...opts, dev: false, sourceNames: false }).code); + expect(prod).toBe(transform(code, { ...opts, dev: false, sourceNames: {} }).code); + + const off = transform(code, { ...opts, dev: true, sourceNames: false }).code; + expect(off).not.toContain('"Home"'); + expect(off).not.toContain("name:"); + const picked = transform(code, { ...opts, dev: true, sourceNames: { components: false } }).code; + expect(picked).not.toContain('"Home"'); + expect(picked).toContain(binding); + const prodPicked = transform(code, { ...opts, sourceNames: { components: true } }).code; + expect(prodPicked).toContain(label); + expect(prodPicked).not.toContain(binding); + }); + it("rejects unsupported dynamic renderer config instead of ignoring it", () => { expect(() => transform("const view =
;", { diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 7edcbdd29..9e1869409 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -53,8 +53,15 @@ pub struct Renderer { /// Babel's `sourceNames`, resolved: which names as written in source the /// output carries so the dev and observe runtimes can label the reactive -/// graph after minification. Every kind off by default; the production -/// runtimes ignore the names. +/// graph after minification. The core takes every kind explicitly (the +/// `Default` is all off); the Node adapter resolves the public `sourceNames` +/// option against `dev`, so on the JavaScript surface each kind defaults to +/// the `dev` flag. The production runtimes ignore the names. +/// +/// These are the JSX-level kinds only. Primitive names (`createSignal(0, +/// { name: "count" })`) come from the standalone `transformSourceNames` +/// pass, which the build tool runs on every module independently of the +/// JSX compiler. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SourceNames { /// The tag as written, as `createComponent`'s third argument @@ -68,6 +75,16 @@ pub struct SourceNames { pub bindings: bool, } +impl SourceNames { + /// Every kind set to `enabled`. + pub fn all(enabled: bool) -> Self { + Self { + components: enabled, + bindings: enabled, + } + } +} + /// Default runtime import path — same as `@solidjs/babel-plugin` and the /// deleted `babel-preset-solid`. pub(crate) const DEFAULT_MODULE_NAME: &str = "@solidjs/web"; diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs index 5e01c95a4..4c5d4245f 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -47,7 +47,11 @@ pub struct TransformOptions { /// (`span.textContent`, `div.class:active`, a hole `div.children`, a /// spread `div.spread`) through an options argument on /// `effect`/`insert`/`spread`; DOM output only. The production runtimes - /// ignore the names. `true` enables every kind; the object form picks. + /// ignore the names. Defaults to `dev`: unset, every kind is on in dev + /// and off otherwise; `true`/`false` sets every kind; the object form + /// picks, and each kind it leaves unspecified follows `dev`. Primitive + /// names are not a kind here: they come from the standalone + /// `transformSourceNames` pass the build tool runs on every module. pub source_names: Option>, pub source_map: Option, pub context_to_custom_elements: Option, diff --git a/packages/compiler/src/node_adapter.rs b/packages/compiler/src/node_adapter.rs index bf00d9c23..6708ca3cd 100644 --- a/packages/compiler/src/node_adapter.rs +++ b/packages/compiler/src/node_adapter.rs @@ -261,16 +261,20 @@ fn core_options(options: TransformOptions) -> Result { server_components: options.server_components.unwrap_or(false), hoist_props: options.hoist_props.unwrap_or(true), dev: options.dev.unwrap_or(false), - source_names: match options.source_names { - None | Some(Either::A(false)) => SourceNames::default(), - Some(Either::A(true)) => SourceNames { - components: true, - bindings: true, - }, - Some(Either::B(picked)) => SourceNames { - components: picked.components.unwrap_or(false), - bindings: picked.bindings.unwrap_or(false), - }, + // `sourceNames` follows `dev`: unset, every kind is `dev`; in the + // object form each kind left unspecified is `dev`. Production output + // is untouched unless asked for. Same resolution as the Babel + // plugin's `sourceNames()`. + source_names: { + let dev = options.dev.unwrap_or(false); + match options.source_names { + None => SourceNames::all(dev), + Some(Either::A(enabled)) => SourceNames::all(enabled), + Some(Either::B(picked)) => SourceNames { + components: picked.components.unwrap_or(dev), + bindings: picked.bindings.unwrap_or(dev), + }, + } }, source_map: options.source_map.unwrap_or(false), context_to_custom_elements: options.context_to_custom_elements.unwrap_or(true), diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index d22ec6851..88ba6e033 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -30,8 +30,13 @@ export interface TransformOptions { * binding effect named by what it writes — `span.textContent`, * `div.class:active`, a hole `div.children`, a spread `div.spread` — as * an options argument on `effect`/`insert`/`spread`; DOM output only. - * The production runtimes ignore the names. `true` enables every kind; - * an object picks. + * The production runtimes ignore the names. Defaults to `dev`: unset, + * every kind is on in dev and off otherwise; `true`/`false` sets every + * kind; an object picks, and each kind it leaves unspecified follows + * `dev`. Primitive names (`createSignal(0, { name: "count" })`) are not a + * kind here — they come from the standalone `transformSourceNames` pass, + * which the build tool runs on every module independently of this + * transform. */ sourceNames?: boolean | SourceNamesOptions; sourceMap?: boolean; @@ -262,8 +267,12 @@ export function transformRefreshAsync( * non-component function the name is prefixed with that function's * (`createCounter.count`). Only calls resolving to imports from `solid-js` / * `@solidjs/signals` are named, and an explicit `name` is never overridden. - * Plain JavaScript in and out, so it applies to `.ts`/`.js` modules too; - * `@solidjs/vite-plugin` runs it ahead of the JSX transform. + * Plain JavaScript in and out, so it applies to `.ts`/`.js` modules too. + * This standalone pass is the single owner of primitive naming: the build + * tool (`@solidjs/vite-plugin`) runs it on every module — `.ts`, `.js` and + * JSX files alike — independently of which JSX compiler (this one or + * `@solidjs/babel-plugin`) handles the file's JSX. `transform()`'s + * `sourceNames` option covers only the JSX-level kinds. */ export interface TransformSourceNamesOptions { /** Picks the parser dialect (`.ts`, `.tsx`, `.js`, `.jsx`); TSX without one. */ diff --git a/packages/diagnostics/README.md b/packages/diagnostics/README.md index cca49fae5..e920adb69 100644 --- a/packages/diagnostics/README.md +++ b/packages/diagnostics/README.md @@ -57,7 +57,7 @@ artifact.records.invocation; // every server-function execution: id, durationMs, artifact.records.frame; // every frame stream produced (side: "server"): id, shellMs, durationMs, outcome, the chunk census ``` -`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 — `ASYNC_WATERFALL` (server) for a sequential chain and `SSR_CLIENT_CONTENT_MASKED` for client-only content that surfaced only after a wait — so `expectNoDiagnostics` catches them without reading the tables. +`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. diff --git a/packages/diagnostics/skills/agent-loops/SKILL.md b/packages/diagnostics/skills/agent-loops/SKILL.md index 886babdd7..f966ca4ac 100644 --- a/packages/diagnostics/skills/agent-loops/SKILL.md +++ b/packages/diagnostics/skills/agent-loops/SKILL.md @@ -189,8 +189,8 @@ record type: Read them together: a boundary's `durationMs` is the sum of its passes' waits, and the invocations with its `id` are what those waits were spent on. A boundary with `passes: 3` and two invocations under it in sequence is a -waterfall; the runtime already says so (`ASYNC_WATERFALL` with -`data.side: "server"`, `warn` from three flights), and `SSR_CLIENT_CONTENT_MASKED` +waterfall; the runtime already says so (`SSR_BOUNDARY_WATERFALL`, `warn` +from three waits), and `SSR_CLIENT_CONTENT_MASKED` when a client-only read surfaced only after a wait — so Loop 1's rule holds on the server: capture, read the codes, repair, re-capture. Use `attribution: false` here; the engine has nothing to see in a server render. diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 5b09cf968..b164af074 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -12,6 +12,7 @@ import { liveRootOwners, isExcluded, isSuppressed, + noteFanOut, OBSERVE, ownerPath, reportDiagnostic @@ -359,7 +360,7 @@ export interface AttributionOptions { /** * Run the cost checks — the thresholded findings over the engine's own * accounting: `hotRuns`, `hotTime`, `wideDeps`, `unstableMemos`, - * `wideWrites`, `wastedRecompute` (default true). `false` turns all six off at once, whatever + * `fanOut`, `wastedRecompute` (default true). `false` turns all six off at once, whatever * their individual settings, so a consumer that wants records only (an * exporter, a profiler track) pays for none of their per-node bookkeeping. * Hold, long-hold and waterfall tracking are records with verdicts on top, @@ -412,15 +413,16 @@ export interface AttributionOptions { */ wastedRecompute?: { minRuns: number; ratio: number; budgetMs: number; windowMs: number } | false; /** - * Written-fan-out warning: emit a diagnostic when a committed root - * invalidation (write, refresh, async landing) reaches a node with at - * least this many subscribers (default 250). The lower-bar, opt-in sibling - * of the always-on HUGE_FAN_OUT graph-size warning, which fires on the - * same kind of write from GRAPH_SIZE_WARN_AT (2000) up; this one hands - * over to it there, so a write never carries both. Once per node, - * re-warning only on 2x subscriber growth. `false` disables. + * HUGE_FAN_OUT threshold while the engine is enabled: emit the finding + * when a committed root invalidation (write, refresh, async landing) + * reaches a node with at least this many subscribers (default 250). The + * same code the always-on core check emits from GRAPH_SIZE_WARN_AT (2000) + * up, with `data.write` naming the invalidation; the engine hands over to + * the core there, so one change never carries two findings. Once per + * node, re-warning once the count has grown by another 500. `false` + * leaves only the always-on threshold. */ - wideWrites?: number | false; + fanOut?: number | false; /** * Async-waterfall warning: emit a diagnostic when an async flight that * could only start after an upstream flight resolved (its recompute's @@ -524,7 +526,6 @@ interface AttributedNode { _devTimeWarned?: boolean; _devUnstableRuns?: number; _devUnstableWarned?: boolean; - _devWideWriteWarnedAt?: number; /** Longest sequential-flight chain already warned for this node. */ _devWaterfallWarnedAt?: number; /** WASTED_RECOMPUTE window: runs, no-op runs and their self-time since `_devWasteWinStart`. */ @@ -579,7 +580,7 @@ const defaultOptions = { wastedRecompute: { minRuns: 5, ratio: 0.8, budgetMs: 2, windowMs: 1000 } as | { minRuns: number; ratio: number; budgetMs: number; windowMs: number } | false, - wideWrites: 250 as number | false, + fanOut: 250 as number | false, waterfalls: { minFlightMs: 50 } as { minFlightMs: number } | false, holds: { infoMs: 100, warnMs: 200 } as { infoMs: number; warnMs: number } | false, longHolds: { infoMs: 500, warnMs: 1000 } as { infoMs: number; warnMs: number } | false, @@ -999,46 +1000,23 @@ function countSubscribers(node: Signal | Computed): number { } /** - * Written-fan-out warning — the engine's lower-bar sibling of the always-on - * HUGE_FAN_OUT (see dev.ts): a committed root invalidation reaching hundreds - * of subscribers re-runs all of them this flush. Counts the subscriber list - * itself (an engine-only walk, on the write; the core keeps no per-node - * count — a live `_subCount` was a post-construction field that forked node - * shapes). Once per node; re-warns only when the subscriber count has - * doubled since the last warning. Stops at GRAPH_SIZE_WARN_AT, where - * HUGE_FAN_OUT takes over, so the two never fire for the same write. + * HUGE_FAN_OUT from the engine's lower `fanOut` threshold (see dev.ts): a + * committed root invalidation reaching hundreds of subscribers re-runs all + * of them this flush. Counts the subscriber list itself (an engine-only + * walk, on the write; the core keeps no per-node count — a live `_subCount` + * was a post-construction field that forked node shapes). Stops at + * GRAPH_SIZE_WARN_AT, where the always-on core check takes over, so the two + * never fire for the same write; the once-per-node dedupe is the core's. */ -function checkWideWrite( +function checkFanOut( node: Signal | Computed, kind: Exclude ): void { - const limit = options.wideWrites; + const limit = options.fanOut; if (typeof limit !== "number") return; const subs = countSubscribers(node); - const attributed = node as AttributedNode; if (subs < limit || subs >= GRAPH_SIZE_WARN_AT) return; - if (subs < (attributed._devWideWriteWarnedAt ?? 0) * 2) return; - attributed._devWideWriteWarnedAt = subs; - const verb = - kind === "refresh" ? "refresh of" : kind === "async" ? "async landing on" : "write to"; - const message = - `[WIDE_WRITE] ${verb} "${nodeName(node)}" reached ${subs} subscribers — every one ` + - `re-runs this flush. If consumers ask keyed questions of this value (for example every ` + - `row comparing against one selected id), invert it: keep the answer in a store used as a ` + - `map keyed by id, so each consumer reads its own key and only the keys that flipped update.`; - reportDiagnostic( - emitDiagnostic( - { - code: "WIDE_WRITE", - kind: "perf", - severity: "warn", - message, - nodeName: nodeName(node), - data: { subscribers: subs, write: kind } - }, - node - ) - ); + noteFanOut(node, subs, kind); } function stampWrite( @@ -1067,8 +1045,8 @@ function stampWrite( if (kind === "write") trackEffectWrite(node, record, value); // stampWrite is the single funnel for committed root invalidations (sync // writes, refresh(), async landings), which makes it the one place the - // written-fan-out check needs to live. - checkWideWrite(node, kind); + // engine's fan-out check needs to live. + checkFanOut(node, kind); } /** Record a derived change (memo produced a new value) with its causes. */ @@ -4265,7 +4243,7 @@ function resolveHold(opts: AttributionOptions | undefined): Options { out.hotTime = false; out.wideDeps = false; out.unstableMemos = false; - out.wideWrites = false; + out.fanOut = false; out.wastedRecompute = false; } return out; diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 73cb168cd..9e685e581 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -75,7 +75,6 @@ export type DiagnosticCode = | "WIDE_SCOPE_DEPS" | "UNSTABLE_MEMO_OUTPUT" | "WASTED_RECOMPUTE" - | "WIDE_WRITE" | "ASYNC_WATERFALL" | "HOT_SCOPE_FANOUT" | "SILENT_HOLD" @@ -96,8 +95,8 @@ export type DiagnosticCode = | "SSR_STREAM_ABANDONED" | "SSR_CLIENT_CONTENT_MASKED" | "LATE_HEADER_WRITE" - | "SERVER_FN_ERROR_SANITIZED" - | "SSR_ERROR_SANITIZED" + | "SERVER_ERROR_SANITIZED" + | "SSR_BOUNDARY_WATERFALL" | "SERVER_WRITE" | "REVEAL_IN_RENDER_TO_STRING" | "LAZY_ASSET_UNMAPPED" @@ -884,17 +883,34 @@ function shouldWarnGraphSize(node: object, count: number): boolean { return true; } +/** + * The root invalidation a HUGE_FAN_OUT finding was priced on, when the + * attribution engine is the one reporting it: a signal/store write, a + * `refresh()`, or an async landing. The always-on core check counts the + * notify walk of any change and cannot tell — it reports no `write`. + */ +export type FanOutWrite = "write" | "refresh" | "async"; + /** * Observe-tier: a committed change on `node` is about to re-run `count` - * subscribers (the notify walk in `insertSubs` counted them as it went — - * fan-out costs exactly one local increment in a loop that already visits - * every edge, and nothing at link time). Fires from GRAPH_SIZE_WARN_AT up, - * on the write rather than the link: a fan-out that is never written costs - * nothing, and one that is re-runs every subscriber this flush. Always-on - * wherever the channel exists — unlike the opt-in attribution engine, a - * graph-size pathology should surface without asking. + * subscribers. Two reporters, one finding, one dedupe: the core counts the + * notify walk in `insertSubs` as it goes (fan-out costs exactly one local + * increment in a loop that already visits every edge, and nothing at link + * time) and fires from GRAPH_SIZE_WARN_AT up, always-on wherever the + * channel exists — a graph-size pathology should surface without asking. + * The attribution engine, while enabled, reports the same code from its + * lower `fanOut` threshold (default 250) on the root writes it stamps, with + * the write kind it knows, and hands over to the core at + * GRAPH_SIZE_WARN_AT so one change never carries two findings. Both fire on + * the write rather than the link: a fan-out that is never written costs + * nothing, and one that is re-runs every subscriber this flush. Once per + * node, re-warning only once the count has grown by GRAPH_SIZE_WARN_EVERY. */ -export function noteFanOut(node: Signal | Computed, count: number): void { +export function noteFanOut( + node: Signal | Computed, + count: number, + write?: FanOutWrite +): void { if (!shouldWarnGraphSize(node, count)) return; const name = node._name; const message = @@ -912,7 +928,7 @@ export function noteFanOut(node: Signal | Computed, count: number): vo nodeName: name, ownerId: (node as Computed).id, ownerName: name, - data: { count } + data: write === undefined ? { count } : { count, write } }, node ) diff --git a/packages/signals/tests/attribution-benchmark-eval.test.ts b/packages/signals/tests/attribution-benchmark-eval.test.ts index e9fa484a1..6b6fb1cd3 100644 --- a/packages/signals/tests/attribution-benchmark-eval.test.ts +++ b/packages/signals/tests/attribution-benchmark-eval.test.ts @@ -68,7 +68,7 @@ describe("JSFB select-row (naive: every row reads the selected signal)", () => { return setSelected; } - it("WIDE_WRITE identifies the selection fan-out at default thresholds", () => { + it("HUGE_FAN_OUT identifies the selection fan-out at the engine's default threshold", () => { const setSelected = naiveRows(1000); const { diagnostics, reruns } = arm(); @@ -78,11 +78,11 @@ describe("JSFB select-row (naive: every row reads the selected signal)", () => { flush(); // The culprit is named: the write to selectedId, with its subscriber count. - const wide = diagnostics.filter(e => e.code === "WIDE_WRITE"); + const wide = diagnostics.filter(e => e.code === "HUGE_FAN_OUT"); expect(wide).toHaveLength(1); expect(wide[0].nodeName).toBe("selectedId"); - expect(wide[0].data!.subscribers).toBe(1000); - expect(wide[0].message).toContain("store used as a map keyed by id"); + expect(wide[0].data).toEqual({ count: 1000, write: "write" }); + expect(wide[0].message).toContain("per-key store or projection"); expect(wide[0].message).not.toContain("createSelector"); // FINDING (F2), now fixed engine-side: effects run with `_equals: false`, @@ -117,7 +117,7 @@ describe("JSFB select-row (naive: every row reads the selected signal)", () => { const setSelected = naiveRows(50); const { diagnostics } = arm({ hotRuns: { count: 10, windowMs: 60_000 }, - wideWrites: 25, + fanOut: 25, hotTime: false }); @@ -133,8 +133,8 @@ describe("JSFB select-row (naive: every row reads the selected signal)", () => { expect(fanout[1].data).toMatchObject({ cause: "selectedId", scopes: 50 }); expect(fanout[1].message).toContain("store used as a map keyed by id"); expect(fanout[1].message).not.toContain("createSelector"); - // WIDE_WRITE fired once and named the actual culprit. - expect(diagnostics.filter(e => e.code === "WIDE_WRITE")).toHaveLength(1); + // HUGE_FAN_OUT fired once and named the actual culprit. + expect(diagnostics.filter(e => e.code === "HUGE_FAN_OUT")).toHaveLength(1); }); it("stays quiet on the selector-inverted version (the correct fix)", () => { diff --git a/packages/signals/tests/attribution-wide-write.test.ts b/packages/signals/tests/attribution-fan-out.test.ts similarity index 54% rename from packages/signals/tests/attribution-wide-write.test.ts rename to packages/signals/tests/attribution-fan-out.test.ts index 5bc863988..468b7f8ca 100644 --- a/packages/signals/tests/attribution-wide-write.test.ts +++ b/packages/signals/tests/attribution-fan-out.test.ts @@ -10,6 +10,7 @@ import { OBSERVE } from "../src/index.js"; import type { DiagnosticEvent } from "../src/core/dev.js"; +import { GRAPH_SIZE_WARN_AT, GRAPH_SIZE_WARN_EVERY } from "../src/core/dev.js"; afterEach(() => { attribution.disable(); @@ -17,13 +18,13 @@ afterEach(() => { vi.restoreAllMocks(); }); -/** Enable quietly and capture WIDE_WRITE diagnostics. */ -function captureWideWrites(wideWrites: number | false = 250) { +/** Enable quietly and capture HUGE_FAN_OUT diagnostics. */ +function captureFanOut(fanOut: number | false = 250) { vi.spyOn(console, "warn").mockImplementation(() => {}); - attribution.enable({ log: false, hotRuns: false, hotTime: false, wideWrites }); + attribution.enable({ log: false, hotRuns: false, hotTime: false, fanOut }); const events: DiagnosticEvent[] = []; OBSERVE!.diagnostics.subscribe(e => { - if (e.code === "WIDE_WRITE") events.push(e); + if (e.code === "HUGE_FAN_OUT") events.push(e); }); return events; } @@ -37,44 +38,64 @@ function subscribeN(read: () => unknown, n: number): void { flush(); } -describe("WIDE_WRITE", () => { +// The engine's lower-threshold reporter of the always-on HUGE_FAN_OUT: same +// code, same `data.count`, plus `data.write` naming the root invalidation. +describe("HUGE_FAN_OUT from the attribution engine's fanOut threshold", () => { it("warns once when a write reaches the subscriber threshold", () => { const [selectedId, setSelectedId] = createSignal(0, { name: "selectedId" }); subscribeN(() => selectedId(), 30); - const events = captureWideWrites(25); + const events = captureFanOut(25); setSelectedId(1); flush(); setSelectedId(2); // same node, same size — muted flush(); expect(events).toHaveLength(1); + expect(events[0].code).toBe("HUGE_FAN_OUT"); + expect(events[0].kind).toBe("graph"); + expect(events[0].severity).toBe("warn"); expect(events[0].nodeName).toBe("selectedId"); - expect(events[0].data).toMatchObject({ subscribers: 30, write: "write" }); + expect(events[0].data).toEqual({ count: 30, write: "write" }); + expect(events[0].message).toContain('[HUGE_FAN_OUT] Signal "selectedId" changed with 30 subscribers'); // The repair must name an API 2.0 ships (#3304). - expect(events[0].message).toContain("store used as a map keyed by id"); + expect(events[0].message).toContain("per-key store or projection"); expect(events[0].message).not.toContain("createSelector"); }); - it("re-warns only after the subscriber count doubles", () => { + it("re-warns only once the subscriber count has grown by GRAPH_SIZE_WARN_EVERY", () => { const [n, setN] = createSignal(0, { name: "n" }); subscribeN(() => n(), 30); - const events = captureWideWrites(25); + const events = captureFanOut(25); setN(1); flush(); expect(events).toHaveLength(1); - subscribeN(() => n(), 25); // 55 total — under 2x of 30 + subscribeN(() => n(), GRAPH_SIZE_WARN_EVERY - 10); // under the growth step setN(2); flush(); expect(events).toHaveLength(1); - subscribeN(() => n(), 10); // 65 total — past 2x of 30 + subscribeN(() => n(), 20); // past it setN(3); flush(); expect(events).toHaveLength(2); - expect(events[1].data!.subscribers as number).toBeGreaterThanOrEqual(60); + expect(events[1].data!.count as number).toBeGreaterThanOrEqual(30 + GRAPH_SIZE_WARN_EVERY); + }); + + it("hands over to the always-on check at GRAPH_SIZE_WARN_AT — one finding per write", () => { + const [n, setN] = createSignal(0, { name: "n" }); + subscribeN(() => n(), GRAPH_SIZE_WARN_AT); + + const events = captureFanOut(25); + setN(1); + flush(); + + // The core's notify-walk count fired; the engine did not add a second. + expect(events).toHaveLength(1); + expect(events[0].data).toEqual({ count: GRAPH_SIZE_WARN_AT }); + expect(events[0].message).toContain('Signal "n" changed with'); }); it("fires for refresh() invalidations of wide memos", () => { @@ -82,36 +103,36 @@ describe("WIDE_WRITE", () => { const doubled = createMemo(() => n() * 2, { name: "doubled" }); subscribeN(() => doubled(), 30); - const events = captureWideWrites(25); + const events = captureFanOut(25); refresh(doubled); flush(); expect(events).toHaveLength(1); expect(events[0].nodeName).toBe("doubled"); expect(events[0].data).toMatchObject({ write: "refresh" }); - expect(events[0].message).toContain('refresh of "doubled"'); + expect(events[0].message).toContain('Signal "doubled" changed with'); }); it("stays quiet under the threshold and for unchanged writes", () => { const [n, setN] = createSignal(0, { name: "n" }); subscribeN(() => n(), 30); - const events = captureWideWrites(31); + const events = captureFanOut(31); setN(1); // 30 subscribers < 31 flush(); expect(events).toHaveLength(0); - const wide = captureWideWrites(25); + const wide = captureFanOut(25); setN(1); // equality gate: same value commits nothing, stamps nothing flush(); expect(wide).toHaveLength(0); }); - it("can be disabled", () => { + it("can be disabled, leaving only the always-on threshold", () => { const [n, setN] = createSignal(0, { name: "n" }); subscribeN(() => n(), 30); - const events = captureWideWrites(false); + const events = captureFanOut(false); setN(1); flush(); expect(events).toHaveLength(0); diff --git a/packages/signals/tests/attribution.test.ts b/packages/signals/tests/attribution.test.ts index 8ef4ecacc..6f5dcd8a5 100644 --- a/packages/signals/tests/attribution.test.ts +++ b/packages/signals/tests/attribution.test.ts @@ -357,7 +357,7 @@ describe("why-did-this-run attribution", () => { collect({ hotRuns: { count: 3, windowMs: 60_000 }, wideDeps: false, - wideWrites: false, + fanOut: false, hotTime: false }); const capture = OBSERVE!.diagnostics.capture(); diff --git a/packages/signals/tests/diagnostics.test.ts b/packages/signals/tests/diagnostics.test.ts index 2a85d245a..d9fe1b308 100644 --- a/packages/signals/tests/diagnostics.test.ts +++ b/packages/signals/tests/diagnostics.test.ts @@ -436,7 +436,7 @@ describe("diagnostics owner path", () => { }); // No ambient context here: the event locates via the explicit subject. const event = emitDiagnostic( - { code: "WIDE_WRITE", kind: "perf", severity: "warn", message: "m" }, + { code: "HUGE_FAN_OUT", kind: "graph", severity: "warn", message: "m" }, node ); // A signal is not itself an owner; its path is its registering owner's. diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md index 2eba4d08c..25a1a06bb 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -210,9 +210,12 @@ These fire only while attribution is enabled and describe cost, not incorrect behavior. The numbers in the message are measurements, not guesses. -### HUGE_FAN_OUT / WIDE_WRITE +### HUGE_FAN_OUT -One value has very many subscribers, so a single change re-runs all of them. +One value has very many subscribers, so a single change re-runs all of them +(`data.count`). Always on from 2000; while attribution is enabled the same +code fires from `fanOut` (default 250) on a stamped root write, and +`data.write` says which — a `write`, a `refresh`, or an `async` landing. Classic signature: every row of a list comparing against one selected id. Invert the question: keep the answer in a store used as a map keyed by id (`selected[row.id]` instead of `row.id === selectedId()`), so each consumer @@ -313,7 +316,8 @@ Async flights ran in sequence when they might have run in parallel: each named flight provably could not start until the previous one resolved, and each took real time (the per-link durations are in the message/data). Read the chain from `attribution.history("waterfall")` if you need more than the -warning shows. Repairs, in order of preference: +warning shows. (The server's `` boundary counterpart is +`SSR_BOUNDARY_WATERFALL`, below.) Repairs, in order of preference: 1. If a later request does not need the earlier response, derive both from the same inputs so they start together (in one scope, read ALL async @@ -643,10 +647,12 @@ Every hold, flight and show counts here at any duration; `SILENT_HOLD` and ## Server rendering The server runtime reports on the same channel, with the same `in › -` line. Two groups. **Findings** (`SSR_*`, `LATE_HEADER_WRITE`, -`SERVER_FN_ERROR_SANITIZED`, `SSR_ERROR_SANITIZED`, `FRAME_MARKER_CORRUPTED`) are facts about a -render that exist in observe builds too — an APM sees them in production; in -dev they print. **Checks** (the rest) are dev-only guidance. A captured +` line. Two groups. **Findings** (`SSR_RENDER_ERROR_CONTAINED`, +`SSR_SUBTREE_ABANDONED`, `SSR_STREAM_ABANDONED`, `LATE_HEADER_WRITE`, +`SERVER_ERROR_SANITIZED`, `FRAME_MARKER_CORRUPTED`) are facts about a render +that exist in observe builds too — an APM sees them in production; in dev +they print. **Checks** (the rest, `SSR_BOUNDARY_WATERFALL` and +`SSR_CLIENT_CONTENT_MASKED` included) are dev-only guidance. A captured artifact from a server render carries both. ### SSR_RENDER_ERROR_CONTAINED @@ -683,26 +689,28 @@ write before the first flush — before any `` fallback can ship — or before the handler returns; a cookie set from inside a late-streaming component never reaches the browser. -### SERVER_FN_ERROR_SANITIZED - -A server function threw and the production wire replaced the error with the -generic message; `data.error` is the original. Fix the failure it names. If -the client is meant to see this error, brand it with `markSafeError` or map -it in `wrapInvocation`; do not turn sanitization off. - -### SSR_ERROR_SANITIZED - -A render failure reached the client — an `` record, a rejected async -source in the stream, a fragment's rejection, a frame's error chunk — and the -production wire replaced it with the generic `Error`; `data.error` is the -original. Advisory: the failure itself is the `SSR_RENDER_ERROR_CONTAINED` -finding beside it — fix that. If the client is meant to see this error, brand -it with `markSafeError`; do not turn sanitization off. A fallback that prints -`err().message` shows "Internal Server Error" in production by design. To -_report_ these failures from a production build (no `OBSERVE`), register the -server error hook — `configureServerErrors({ onError })` from `@solidjs/web` -— which hears every handled failure once, and may return the value the client -should see instead. +### SERVER_ERROR_SANITIZED + +The production wire replaced an error with the generic `Error`; `data.error` +is the original, `data.wire` what the client got, and `data.source` says +which road: + +- `"server-function"` (`error`): a server function threw. Fix the failure it + names. If the client is meant to see this error, brand it with + `markSafeError` or map it in `wrapInvocation`; do not turn sanitization + off. +- `"ssr"` (`info`): a render failure reached the client — an `` + record, a rejected async source in the stream, a fragment's rejection, a + frame's error chunk. Advisory: the failure itself is the + `SSR_RENDER_ERROR_CONTAINED` finding beside it — fix that. If the client is + meant to see this error, brand it with `markSafeError`; do not turn + sanitization off. A fallback that prints `err().message` shows "Internal + Server Error" in production by design. + +To _report_ these failures from a production build (no `OBSERVE`), register +the server error hook — `configureServerErrors({ onError })` from +`@solidjs/web` — which hears every handled failure once, and may return the +value the client should see instead. ### FRAME_MARKER_CORRUPTED @@ -725,18 +733,18 @@ a subscription, make the subscription the async source itself. Nested `` with `collapsed`/`together` needs a stream to coordinate on; `renderToString` has none. Use `renderToStream`, or drop the ordering. -### ASYNC_WATERFALL (server) - -`data.side: "server"`. A `` boundary rendered in `data.passes` -passes — discovery, then one per wait — and each pass past the first is a -read that could only start once the previous pass's async answered: -`passes - 1` sequential flights, `data.sequentialMs` end to end. Unlike the -client's verdict this proof is exact (the pass structure IS the chain), so -there is no `markFlight` false positive to rule out; the same repairs apply, -in the same order: derive both reads from the same inputs so they start -together (read ALL async sources before using any), or, if the dependency is -intrinsic, preload the dependent data or join the requests. Two flights are -`info` — a lead; three or more `warn`. The boundary is `data.boundary`; a +### SSR_BOUNDARY_WATERFALL + +A `` boundary rendered in `data.passes` passes — discovery, then +one per wait — and each pass past the first is a read that could only start +once the previous pass's async answered: `passes - 1` sequential waits, +`data.sequentialMs` end to end. Unlike the client's `ASYNC_WATERFALL` this +proof is exact (the pass structure IS the chain), so there is no `markFlight` +false positive to rule out; the same repairs apply, in the same order: +derive both reads from the same inputs so they start together (read ALL +async sources before using any), or, if the dependency is intrinsic, preload +the dependent data or join the requests. Two waits are `info` — a lead; +three or more `warn`. The boundary is `data.boundary`; a captured artifact has its record in `artifact.records.boundary` (same `id`) and the server-function calls under it in `artifact.records.invocation` (`boundary` field). diff --git a/packages/solid/src/console-footer.ts b/packages/solid/src/console-footer.ts index 4b911b452..2f2049fd9 100644 --- a/packages/solid/src/console-footer.ts +++ b/packages/solid/src/console-footer.ts @@ -4,8 +4,8 @@ // fix lives without any prior knowledge of the skill system. // // Perf/graph/responsiveness codes additionally name the attribution surface. -// This breaks a discovery circularity: the sensitive detectors (WIDE_WRITE, -// HOT_SCOPE_*, ASYNC_WATERFALL, SILENT_HOLD, …) only fire while the +// This breaks a discovery circularity: the sensitive detectors (HUGE_FAN_OUT +// at the engine's threshold, HOT_SCOPE_*, ASYNC_WATERFALL, SILENT_HOLD, …) only fire while the // `solid-js/attribution` engine is enabled, and a reader who doesn't know the // entry exists never enables it — so the always-on graph warnings (and any such // code that does fire) are the moments to teach that deeper evidence is one diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index ab70ebefc..f195f1c02 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -68,7 +68,7 @@ export const inServerComponentScope: () => boolean = core.inServerComponentScope * Server: the value the client may see in place of a render failure about to * be serialized or rendered for it — the value itself in the dev build or * when branded with `markSafeError`, else one generic `Error` per original - * (recorded once as `SSR_ERROR_SANITIZED`). `subject` locates the finding; + * (recorded once as `SERVER_ERROR_SANITIZED`, `data.source: "ssr"`). `subject` locates the finding; * `null` from a serialization funnel. Client: identity. */ export const ssrSanitizeError: ( diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 2e031ee9a..f967aac2c 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -177,26 +177,27 @@ function ssrLoadingBoundary( // that waited — the verdicts an agent would otherwise derive from the // artifact, coded so the console and `expectNoDiagnostics` see them. const checkWaited = (outcome: BoundaryEvent["outcome"], durationMs: number) => { - // Sequential flights: each pass past the first is a wait that could - // only start once the previous answered. Same code and thresholds as - // the client's graph-proved verdict — depth 2 advisory (a dependent - // fetch is sometimes intrinsic), depth 3+ earns the console — but the - // proof here is exact: the pass structure IS the chain. + // Sequential render passes: each pass past the first is a wait that + // could only start once the previous answered. Same thresholds as the + // client's graph-proved ASYNC_WATERFALL — depth 2 advisory (a dependent + // fetch is sometimes intrinsic), depth 3+ earns the console — but its + // own code: the proof here is the boundary's pass structure, not a + // flight chain, and the repair is read off the boundary record. const flights = passes - 1; if (flights >= 2) { const severity = flights > 2 ? "warn" : "info"; devCheck( { - code: "ASYNC_WATERFALL", - kind: "perf", + code: "SSR_BOUNDARY_WATERFALL", + kind: "ssr", severity, message: - `[ASYNC_WATERFALL] ${flights} sequential async flights in a boundary — ` + - `${durationMs.toFixed(0)}ms over ${passes} render passes: each read could start ` + - `only after the previous one answered. If a later read doesn't need the earlier ` + - `answer, derive both from the same inputs so they start together; if the ` + - `dependency is intrinsic, preload the dependent data or join the requests.`, - data: { side: "server", boundary: id, passes, sequentialMs: durationMs } + `[SSR_BOUNDARY_WATERFALL] A boundary took ${passes} render passes — ` + + `${flights} sequential async waits, ${durationMs.toFixed(0)}ms end to end: each ` + + `read could start only after the previous one answered. If a later read doesn't ` + + `need the earlier answer, derive both from the same inputs so they start together; ` + + `if the dependency is intrinsic, preload the dependent data or join the requests.`, + data: { boundary: id, passes, sequentialMs: durationMs } }, o ); diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index ad7e63ffc..402aaa691 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3042,9 +3042,10 @@ function ownerChainLabels(subject: DiagnosticSubject): string[] | undefined { * build or when branded safe, else one generic `Error` per original. The * verdict is cached on the object, so every road hands the client the same * value. Outside dev a replacement is recorded once as - * `SSR_ERROR_SANITIZED` (observe/dev), the original in `data.error` — - * advisory (`info`): the failure itself is the `SSR_RENDER_ERROR_CONTAINED` - * finding's, and this is the record of what the wire carried instead. + * `SERVER_ERROR_SANITIZED` (observe/dev) with `data.source: "ssr"`, the + * original in `data.error` — advisory (`info`): the failure itself is the + * `SSR_RENDER_ERROR_CONTAINED` finding's, and this is the record of what + * the wire carried instead. * `subject` locates it (the boundary's owner; `null` from a funnel). * @internal */ @@ -3082,11 +3083,11 @@ function record( if (IS_OBSERVE) emitFinding( { - code: "SSR_ERROR_SANITIZED", + code: "SERVER_ERROR_SANITIZED", kind: "ssr", severity: "info", - message: `[SSR_ERROR_SANITIZED] Render error replaced before reaching the client: ${errorText(value)}`, - data: { error: value, wire } + message: `[SERVER_ERROR_SANITIZED] Render error replaced before reaching the client: ${errorText(value)}`, + data: { source: "ssr", error: value, wire } }, subject === undefined ? getOwner() : subject ); diff --git a/packages/solid/test/server/server-diagnostics.spec.ts b/packages/solid/test/server/server-diagnostics.spec.ts index 975c536ce..b56c5f8e4 100644 --- a/packages/solid/test/server/server-diagnostics.spec.ts +++ b/packages/solid/test/server/server-diagnostics.spec.ts @@ -299,7 +299,7 @@ describe("tiers, in the built artifacts", () => { "LAZY_ASSET_UNMAPPED", "REVEAL_IN_RENDER_TO_STRING", // The boundary checks read off the boundary record's facts (hydration.ts). - "ASYNC_WATERFALL", + "SSR_BOUNDARY_WATERFALL", "SSR_CLIENT_CONTENT_MASKED" ]) { expect(prod, check).not.toContain(`[${check}]`); diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 701c35c30..65b94c6a4 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -3227,23 +3227,26 @@ function reportDirectFailure(run, id) { export function sanitizeServerError(value) { if (DEV) return value; if (isSafeError(value)) return value; + const wire = new Error(GENERIC_SERVER_ERROR_MESSAGE); // The observe tier's one record of what the wire did not carry: the // original is gone for the client, so it is a finding here (`data.error` // holds it) for the production consumer that wants the real failure. The // invocation channel reports that the call errored; this reports what - // replaced the error. + // replaced the error. Same code as the SSR roads' replacement, told apart + // by `data.source`; `error` severity where SSR's is advisory because no + // other finding carries this failure. if ("_SOLID_OBSERVE_") emitFinding( { - code: "SERVER_FN_ERROR_SANITIZED", + code: "SERVER_ERROR_SANITIZED", kind: "ssr", severity: "error", - message: `[SERVER_FN_ERROR_SANITIZED] Server function error replaced with a generic Error before serialization: ${errorText(value)}`, - data: { error: value } + message: `[SERVER_ERROR_SANITIZED] Server function error replaced with a generic Error before serialization: ${errorText(value)}`, + data: { source: "server-function", error: value, wire } }, null ); - return new Error(GENERIC_SERVER_ERROR_MESSAGE); + return wire; } /** * The url a `GET()` reference's own call requests — the address to preload * (``), prefetch, or fetch by hand — built the diff --git a/packages/web/test/dev-warning.spec.tsx b/packages/web/test/dev-warning.spec.tsx index 0c9fc7b41..fb4af30ca 100644 --- a/packages/web/test/dev-warning.spec.tsx +++ b/packages/web/test/dev-warning.spec.tsx @@ -221,7 +221,7 @@ describe("Deferred root mount", () => { .map(args => String(args[0])) .filter(text => text.includes("Loading boundary")); expect(reports).toHaveLength(1); - expect(reports[0]).toContain("\n in › effect"); + expect(reports[0]).toContain("\n in › span.children"); resolveFn("ready"); await promise; diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index 01aee8715..523f9fc3b 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -713,7 +713,7 @@ describe("enablePerformanceTracks", () => { // shown under ``. (The other effect re-run is render's own // insert at the root, re-placing the Show's output — no owner path.) const effects = rerunSpans(on("Effects"), "Effects"); - expect(effects.map(m => m.label)).toEqual(["effect", " › › effect"]); + expect(effects.map(m => m.label)).toEqual(["effect", " › › span"]); // On Propagation every cause reads as the Show, never as `value`. const propagation = on("Propagation").filter(m => / ← /.test(m.label)); expect(propagation.map(m => m.label)).toEqual([ @@ -721,7 +721,7 @@ describe("enablePerformanceTracks", () => { " › ← ", " › ← ", "effect ← ", - " › › effect ← n" + " › › span ← n" ]); }); diff --git a/packages/web/test/server/diagnostics-server-scenario.spec.tsx b/packages/web/test/server/diagnostics-server-scenario.spec.tsx index 8c989640b..4fe9cd945 100644 --- a/packages/web/test/server/diagnostics-server-scenario.spec.tsx +++ b/packages/web/test/server/diagnostics-server-scenario.spec.tsx @@ -235,14 +235,14 @@ describe("captureArtifact over a server render", () => { const { artifact } = await captureArtifact(() => stream(() => ), { attribution: false }); - expectDiagnostic(artifact, "ASYNC_WATERFALL", { count: 1 }); + expectDiagnostic(artifact, "SSR_BOUNDARY_WATERFALL", { count: 1 }); const [boundary] = artifact.records.boundary; expect(boundary.passes).toBe(4); // The finding and the record are the same boundary. - const finding = artifact.diagnostics.find(e => e.code === "ASYNC_WATERFALL")!; + const finding = artifact.diagnostics.find(e => e.code === "SSR_BOUNDARY_WATERFALL")!; expect(finding.data!.boundary).toBe(boundary.id); expect(finding.severity).toBe("warn"); - expect(() => expectNoDiagnostics(artifact)).toThrow(/ASYNC_WATERFALL/); + expect(() => expectNoDiagnostics(artifact)).toThrow(/SSR_BOUNDARY_WATERFALL/); } finally { warn.mockRestore(); } diff --git a/packages/web/test/server/server-boundary-records.spec.tsx b/packages/web/test/server/server-boundary-records.spec.tsx index 82df57c08..34eb565c2 100644 --- a/packages/web/test/server/server-boundary-records.spec.tsx +++ b/packages/web/test/server/server-boundary-records.spec.tsx @@ -513,7 +513,7 @@ describe("dev checks off the record", () => { return
{data() && (props.depth > 1 ? : "leaf")}
; } - test("ASYNC_WATERFALL: two sequential flights are advisory (structured only)", async () => { + test("SSR_BOUNDARY_WATERFALL: two sequential waits are advisory (structured only)", async () => { function App() { return ( loading}> @@ -523,20 +523,24 @@ describe("dev checks off the record", () => { } const html = await stream(() => ); expect(html).toContain("leaf"); - const [event, ...rest] = byCode("ASYNC_WATERFALL"); + const [event, ...rest] = byCode("SSR_BOUNDARY_WATERFALL"); expect(rest).toHaveLength(0); - expect(event.kind).toBe("perf"); + expect(event.kind).toBe("ssr"); expect(event.severity).toBe("info"); - expect(event.data).toMatchObject({ side: "server", passes: 3 }); - expect(typeof event.data!.sequentialMs).toBe("number"); + expect(event.data).toEqual({ + boundary: placeholderIds(html)[0], + passes: 3, + sequentialMs: expect.any(Number) + }); // Located by component and keyed by the boundary, like the record. expect(event.ownerPath).toEqual(["", ""]); - expect(event.data!.boundary).toBe(placeholderIds(html)[0]); - expect(event.message).toContain("2 sequential async flights"); + expect(event.message).toContain("3 render passes — 2 sequential async waits"); expect(warn).not.toHaveBeenCalled(); + // The client's graph-proved verdict is its own code. + expect(byCode("ASYNC_WATERFALL")).toHaveLength(0); }); - test("ASYNC_WATERFALL: three sequential flights earn the console, once", async () => { + test("SSR_BOUNDARY_WATERFALL: three sequential waits earn the console, once", async () => { function App() { return ( loading}> @@ -545,13 +549,13 @@ describe("dev checks off the record", () => { ); } await stream(() => ); - const [event, ...rest] = byCode("ASYNC_WATERFALL"); + const [event, ...rest] = byCode("SSR_BOUNDARY_WATERFALL"); expect(rest).toHaveLength(0); expect(event.severity).toBe("warn"); - expect(event.data).toMatchObject({ side: "server", passes: 4 }); - expect(event.message).toContain("3 sequential async flights"); + expect(event.data).toMatchObject({ passes: 4 }); + expect(event.message).toContain("4 render passes — 3 sequential async waits"); expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain("[ASYNC_WATERFALL]"); + expect(String(warn.mock.calls[0][0])).toContain("[SSR_BOUNDARY_WATERFALL]"); expect(String(warn.mock.calls[0][0])).toContain("in › "); }); @@ -564,7 +568,7 @@ describe("dev checks off the record", () => { ); } await stream(() => ); - expect(byCode("ASYNC_WATERFALL")).toHaveLength(0); + expect(byCode("SSR_BOUNDARY_WATERFALL")).toHaveLength(0); expect(byCode("SSR_CLIENT_CONTENT_MASKED")).toHaveLength(0); }); diff --git a/packages/web/test/server/server-diagnostics.spec.tsx b/packages/web/test/server/server-diagnostics.spec.tsx index 5aac20051..230766786 100644 --- a/packages/web/test/server/server-diagnostics.spec.tsx +++ b/packages/web/test/server/server-diagnostics.spec.tsx @@ -411,7 +411,7 @@ describe("the observe tier, in the built artifacts", () => { expect(warn).not.toHaveBeenCalled(); }); - test("SERVER_FN_ERROR_SANITIZED: the replaced error is the record", async () => { + test("SERVER_ERROR_SANITIZED (server-function): the replaced error is the record", async () => { // @ts-ignore — the dist file has no adjacent type declarations. const sf = await import("../../server-functions/dist/server.observe.js"); const original = new Error("SELECT * FROM users WHERE token = 'secret'"); @@ -419,11 +419,16 @@ describe("the observe tier, in the built artifacts", () => { expect(replaced).not.toBe(original); expect((replaced as Error).message).not.toContain("secret"); - const [finding, ...rest] = byCode("SERVER_FN_ERROR_SANITIZED"); + const [finding, ...rest] = byCode("SERVER_ERROR_SANITIZED"); expect(rest).toHaveLength(0); expect(finding.kind).toBe("ssr"); + // The server-function road is `error` where SSR's is advisory: no other + // finding carries this failure. expect(finding.severity).toBe("error"); + expect(finding.data!.source).toBe("server-function"); expect(finding.data!.error).toBe(original); + expect(finding.data!.wire).toBe(replaced); + expect(finding.message).toContain("[SERVER_ERROR_SANITIZED] Server function error"); expect(finding.message).toContain("replaced with a generic Error"); expect(finding.message).toContain("secret"); expect(error).not.toHaveBeenCalled(); @@ -432,7 +437,7 @@ describe("the observe tier, in the built artifacts", () => { capture.clear(); const safe = markSafeError(new Error("shown to the client")); expect(sf.sanitizeServerError(safe)).toBe(safe); - expect(byCode("SERVER_FN_ERROR_SANITIZED")).toHaveLength(0); + expect(byCode("SERVER_ERROR_SANITIZED")).toHaveLength(0); }); test("dev checks fold out of the observe artifact; wiring rides it", () => { diff --git a/packages/web/test/server/ssr-error-sanitization.fixture.mjs b/packages/web/test/server/ssr-error-sanitization.fixture.mjs index 81073fbf7..5fd39ccde 100644 --- a/packages/web/test/server/ssr-error-sanitization.fixture.mjs +++ b/packages/web/test/server/ssr-error-sanitization.fixture.mjs @@ -88,6 +88,7 @@ async function withFindings(run) { severity: e.severity, ownerPath: e.ownerPath, message: e.message, + source: e.data && e.data.source, error: e.data && e.data.error !== undefined ? String(e.data.error) : undefined })) }; diff --git a/packages/web/test/server/ssr-error-sanitization.spec.tsx b/packages/web/test/server/ssr-error-sanitization.spec.tsx index 5c4f45f75..baeef3273 100644 --- a/packages/web/test/server/ssr-error-sanitization.spec.tsx +++ b/packages/web/test/server/ssr-error-sanitization.spec.tsx @@ -25,8 +25,9 @@ * sink's error chunk sanitized; the abandonment ledger keeps the original; * - `markSafeError` passes through everywhere, own properties included; * - the observe tier records the replacement once per original as - * `SSR_ERROR_SANITIZED` (advisory — the failure is the - * `SSR_RENDER_ERROR_CONTAINED` finding's), the original in `data.error`. + * `SERVER_ERROR_SANITIZED` with `data.source: "ssr"` (advisory — the + * failure is the `SSR_RENDER_ERROR_CONTAINED` finding's), the original in + * `data.error`. * * The dev/prod line is the build variant (`IS_DEV`): `server.dev.js` keeps * fidelity, the prod and observe artifacts sanitize. That is a property of @@ -49,6 +50,7 @@ interface Finding { severity: string; ownerPath?: string[]; message: string; + source?: string; error?: string; } interface Scenario { @@ -122,7 +124,7 @@ function describeSanitizing(name: string, conditions: string[], observe: boolean expect(html).toContain(`

Item not found|item:42

`); expect(html).toContain('new Error("Item not found")'); expect(html).toContain('query:"item:42"'); - expect(byCode(findings, "SSR_ERROR_SANITIZED")).toHaveLength(0); + expect(byCode(findings, "SERVER_ERROR_SANITIZED")).toHaveLength(0); }); test("a rejected async source serialized into the stream rejects the client with the replacement", () => { @@ -144,7 +146,7 @@ function describeSanitizing(name: string, conditions: string[], observe: boolean test("an Error reached as a VALUE is data, and passes as the author wrote it", () => { const { value: html, findings } = run(conditions).value; expect(html).toContain("field: name is required"); - expect(byCode(findings, "SSR_ERROR_SANITIZED")).toHaveLength(0); + expect(byCode(findings, "SERVER_ERROR_SANITIZED")).toHaveLength(0); }); test("frame streams: the root error chunk and every keyed error chunk carry the replacement", () => { @@ -200,9 +202,10 @@ describe("the observe artifacts' record of it", () => { const { findings } = run(["observe"]).errored; const [contained] = byCode(findings, "SSR_RENDER_ERROR_CONTAINED"); expect(contained.error).toContain("ECONNREFUSED"); - const sanitized = byCode(findings, "SSR_ERROR_SANITIZED"); + const sanitized = byCode(findings, "SERVER_ERROR_SANITIZED"); expect(sanitized).toHaveLength(1); expect(sanitized[0].severity).toBe("info"); + expect(sanitized[0].source).toBe("ssr"); expect(sanitized[0].error).toContain("ECONNREFUSED"); expect(sanitized[0].message).toContain("replaced before reaching the client"); expect(sanitized[0].ownerPath).toEqual([""]); @@ -211,15 +214,15 @@ describe("the observe artifacts' record of it", () => { test("one original, however many roads it took, is one record", () => { const results = run(["observe"]); // The async source's rejection, the fragment's, the boundary's: one. - expect(byCode(results.channel.findings, "SSR_ERROR_SANITIZED")).toHaveLength(1); + expect(byCode(results.channel.findings, "SERVER_ERROR_SANITIZED")).toHaveLength(1); // The fragment's `_fr` rejection and its abandonment ledger: one, and // the ledger's own finding kept the original. - expect(byCode(results.fragment.findings, "SSR_ERROR_SANITIZED")).toHaveLength(1); + expect(byCode(results.fragment.findings, "SERVER_ERROR_SANITIZED")).toHaveLength(1); const [abandoned] = byCode(results.fragment.findings, "SSR_SUBTREE_ABANDONED"); expect(abandoned.error).toContain("ECONNREFUSED"); // The frame's fragment chunk and its live hole's: one. - expect(byCode(results.frameFragment.findings, "SSR_ERROR_SANITIZED")).toHaveLength(1); - expect(byCode(results.frameRoot.findings, "SSR_ERROR_SANITIZED")).toHaveLength(1); + expect(byCode(results.frameFragment.findings, "SERVER_ERROR_SANITIZED")).toHaveLength(1); + expect(byCode(results.frameRoot.findings, "SERVER_ERROR_SANITIZED")).toHaveLength(1); }); test("the production artifacts record nothing — there is no channel", () => { @@ -246,7 +249,7 @@ describe("the development artifacts", () => { expect(html).toContain( 'new Error("connect ECONNREFUSED postgres://app:hunter2@10.0.0.5:5432")' ); - expect(byCode(findings, "SSR_ERROR_SANITIZED")).toHaveLength(0); + expect(byCode(findings, "SERVER_ERROR_SANITIZED")).toHaveLength(0); expect(byCode(findings, "SSR_RENDER_ERROR_CONTAINED")).toHaveLength(1); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 3746871d5..fb7bc8fcd 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -1986,8 +1986,9 @@ module.exports = [ // Observe node shapes (#3324, 2026-09-09): 24.08 -> 24.14 KB, measured // at 24.079 (1 B under the old ratchet). The literal duplication above // is offset here by the engine dropping the live `_subCount`/`_depCount` - // machinery: WIDE_WRITE counts the subscriber list on the write and - // hands over to HUGE_FAN_OUT at 2000. Ratchet restores headroom only. + // machinery: the engine's HUGE_FAN_OUT check counts the subscriber list + // on the write and hands over to the core's at 2000. Ratchet restores + // headroom only. // // Navigations (2026-09-09): 24.14 -> 24.90 KB, measured at 24.86. The // engine's navigation records: the `navigation` origin kind and its From 32377f68a8d050615ed8b0cd1a9a51c56c3c5b28 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 24 Sep 2026 21:17:19 -0700 Subject: [PATCH 2/3] test(bench): pin sourceNames off in the SSR bench lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest compiles with `dev: true`, and `sourceNames` now follows `dev` in both compilers. For SSR output that keeps `createComponent(Comp, props, "Name")` in place of the inlined `Comp(props)`, so the from-source (dev) server runtime runs every component under a labelled transparent owner — `observedComponent`: pool pop + field reset, `` string, closure, try/finally per component instance. CodSpeed priced that at +24% on `search-results: 50 items (renderToString)` and +5.9% on `polymorphic-chain: chain-static` (~1000 component owners per render); the remaining two deltas in the report touch benches with a single component per render and are runtime-environment noise. That owner is the diagnostics tier's cost, not the SSR runtime cost this lane tracks. `vite.config.server-bench.mjs` now sets `sourceNames: false` explicitly so the lane keeps measuring the production component shape it measured before the default followed `dev`. The compilers' default is unchanged; the bench lane only measures what it did on `next`. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/web/vite.config.server-bench.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/vite.config.server-bench.mjs b/packages/web/vite.config.server-bench.mjs index 6aa15e0f0..5b3259511 100644 --- a/packages/web/vite.config.server-bench.mjs +++ b/packages/web/vite.config.server-bench.mjs @@ -17,7 +17,15 @@ const rootDir = resolve(import.meta.dirname); export default defineConfig({ plugins: [ - solidPlugin({ compiler, solid: { generate: "ssr", hydratable: true } }), + // Vitest compiles with `dev: true`, and `sourceNames` follows `dev` in + // both compilers — which, for SSR output, keeps `createComponent(Comp, + // props, "Name")` in place of the inlined `Comp(props)` so the dev/observe + // runtime can run each component under a labelled transparent owner. + // That owner is the diagnostics tier's cost, not the SSR runtime cost + // this lane tracks (search-results: 50 items is ~24% of the render under + // CodSpeed). Pinned off so the lane keeps measuring the production + // component shape the way it did before the default followed `dev`. + solidPlugin({ compiler, solid: { generate: "ssr", hydratable: true, sourceNames: false } }), codspeedPlugin() ], test: { From f9ad287745362514a38a17afd750f8f7a9329e04 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 24 Sep 2026 22:01:56 -0700 Subject: [PATCH 3/3] test(web): perf-tracks label test follows sourceNames dev default (#3646) --- packages/web/test/performance-tracks.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/test/performance-tracks.spec.tsx b/packages/web/test/performance-tracks.spec.tsx index 523f9fc3b..5697ddf29 100644 --- a/packages/web/test/performance-tracks.spec.tsx +++ b/packages/web/test/performance-tracks.spec.tsx @@ -1697,13 +1697,13 @@ describe("enablePerformanceTracks", () => { // A binding directly under the belongs to : a flow control // is a tag the developer wrote, never the component a node belongs to. const binding = rerunSpans(on("Effects"), "Effects").find(m => - m.label.endsWith(" › effect") + m.label.endsWith(" › span") )!; - expect(binding.label).toBe(" › › effect"); + expect(binding.label).toBe(" › › span"); expect(Object.fromEntries(binding.properties!)["Owner path"]).toMatch( /^ › › › / ); - expect(on("Propagation").some(m => m.label === " › › effect ← n")).toBe(true); + expect(on("Propagation").some(m => m.label === " › › span ← n")).toBe(true); }); test("without console.timeStamp or performance.measure it does nothing", () => {