diff --git a/.changeset/dev-artifact-naming.md b/.changeset/dev-artifact-naming.md new file mode 100644 index 000000000..52ab216bf --- /dev/null +++ b/.changeset/dev-artifact-naming.md @@ -0,0 +1,7 @@ +--- +"solid-js": patch +"@solidjs/web": patch +"@solidjs/universal": patch +--- + +Rename the three legacy client dev artifacts to the `.dev.{js,cjs}` convention every other dev build already uses: `solid-js/dist/dev.*` → `dist/solid.dev.*`, `@solidjs/web/dist/dev.*` → `dist/web.dev.*`, `@solidjs/universal/dist/dev.*` → `dist/universal.dev.*`. With server dev builds now shipping as `dist/server.dev.*`, a bare `dev.js` no longer says which entry it is the dev build of. The `exports` maps are updated; only code deep-importing `dist/dev.js` directly (bypassing `exports`) is affected. diff --git a/.changeset/server-dev-build.md b/.changeset/server-dev-build.md new file mode 100644 index 000000000..53fdb7dae --- /dev/null +++ b/.changeset/server-dev-build.md @@ -0,0 +1,12 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Add development server builds — `dist/server.dev.*` for `solid-js` and `@solidjs/web`, and `frames/dist/server.dev.*` — selected by the `development` export condition nested under `node`/`worker`/`deno` (nesting is required: those conditions precede the top-level `development` key, so a top-level entry never matched on a server). Until now SSR had no dev build: the only server artifact was built with `_SOLID_DEV_` stripped, so the server runtime's dev checks (head/preload descriptor validation, `useHead` warnings, the committed-response header guard) never ran outside the test suite. + +The server entries now gate their public dev flags on the same `_SOLID_DEV_` replace as their internals instead of hard-coding them: `solid-js`'s server `DEV` is `@solidjs/signals`' `DEV` object in the dev artifact (so `DEV.diagnostics.subscribe`/`capture` work server-side) and `undefined` in prod; `@solidjs/web`'s server `isDev` is `true` in the dev artifact and `false` in prod. + +Behavior change for dev SSR hosts that pass the `development` condition (Vite dev does by default): a header write after the response has committed now **throws** with the offending header named, where the production artifact continues to `console.error` and drop the write. + +Also runs `replaceDev(false)` on `solid-js`'s production server build so a future `_SOLID_DEV_` gate in `src/server/` cannot constant-fold into the dev branch in production. diff --git a/.changeset/signals-dev-cjs.md b/.changeset/signals-dev-cjs.md new file mode 100644 index 000000000..9719ecdcb --- /dev/null +++ b/.changeset/signals-dev-cjs.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Add a development CJS build, `dist/node.dev.cjs`, selected by the `development` condition on the `require` branch of `exports`. Previously `require` always resolved to the production `dist/node.cjs` (`__DEV__` false), so a CJS host that resolved `solid-js`'s dev server artifact would get `DEV === undefined` from its `@solidjs/signals` dependency — a dev server runtime whose diagnostics channel was silently absent. Dev CJS is the unmangled twin of `dist/dev.js`; the production CJS is unchanged. diff --git a/.cursor/rules/web.mdc b/.cursor/rules/web.mdc index 2b31f99f2..2660d7bb3 100644 --- a/.cursor/rules/web.mdc +++ b/.cursor/rules/web.mdc @@ -64,7 +64,10 @@ Browser-safe stubs that log errors if server APIs are accidentally used in brows ## Build Outputs - `dist/web.js` — production browser -- `dist/dev.js` — development browser (with `_SOLID_DEV_` enabled) -- `dist/server.js` — server (Node/Deno/Worker) +- `dist/web.dev.js` — development browser (with `_SOLID_DEV_` enabled) +- `dist/server.js` — production server (Node/Deno/Worker) +- `dist/server.dev.js` — development server (`development` export condition, nested under `node`/`worker`/`deno`) + +Every dev artifact is `.dev.{js,cjs}` beside its prod `.{js,cjs}` — the same rule for `frames/`, `server-functions/`, and the other packages. Dev-ness is a build mode, not an entry. Rollup with Babel for JSX transformation, `@rollup/plugin-replace` for dev/prod flags. diff --git a/documentation/plans/server-dev-build-plan.md b/documentation/plans/server-dev-build-plan.md new file mode 100644 index 000000000..2c6b1015a --- /dev/null +++ b/documentation/plans/server-dev-build-plan.md @@ -0,0 +1,285 @@ +# Server Dev Build Plan — step one of the observability track + +_Drafted 2026-09-06. Status: AGREED 2026-09-06 (decisions D1–D3 below +resolved). **P0 implemented 2026-09-07** on branch `server-dev-build` (see the +P0 status note); P1–P4 not started. `diagnostics-expansion` merged to `next` +as #3302 on 2026-09-07, so D3's ordering constraint on P1/P2 is satisfied. +Precedes every server item in +`documentation/proposals/production-observability-sketch.md` (§9, +§10.2–10.4). Owner: Ryan._ + +## Objective + +Give the server runtime the same dev/prod split the client has, so that +server-side diagnostics (a) exist, (b) run where developers can see them, and +(c) flow through the one structured channel (`DEV.diagnostics`) that the +console, `@solidjs/diagnostics` budgets, and agents already consume. Without +this, every server finding in the sketch has nowhere to go, and the +attribution work has no server foothold. + +## The facts this plan turns on (verified against `next`, 2026-09-06) + +1. **`solid-js` has no server dev build.** `packages/solid/rollup.config.js` + builds `src/server/index.ts` once, with _no_ `_SOLID_DEV_` replace at all; + `dist/server.{js,cjs}` is the only artifact for the `worker`/`deno`/`node` + conditions. `packages/solid/src/server/` contains **zero** `"_SOLID_DEV_"` + gates. Its 8 `console.warn`/`console.error` sites fire **unconditionally in + production**: `[SERVER_WRITE]` (`signals.ts:696`), nested `` in + `renderToString` (`flow.ts:357`), lazy asset resolution failures + (`component.ts:123/284/296/380`), and the `failRender` fallbacks + (`hydration.ts:183/190` — those two are error containment, not + diagnostics, and stay). +2. **`@solidjs/web`'s server dev checks are dead in every deployment.** + `packages/web/src/server.ts` has **26** `"_SOLID_DEV_"` gates (head/preload + descriptor validation ×~15, `useHead` tag/attribute warnings ×~8, + "Unrecognized value. Skipped inserting" ×2, the #2982 late-header throw). + The only server artifact is built with `replaceDev(false)` — by design, per + the rollup comment "SSR builds are production by convention" — so none of + these ever run outside the test suite (which runs from source, where the + string literal is truthy). `head.ts` and `cookies.ts` each have one more. +3. **The pattern already exists in the same package.** + `@solidjs/web/server-functions` ships `server.dev.{js,cjs}` via + `replaceDev(true)` and nests a `development` condition **inside** + `worker`/`deno`/`node` in its exports — the correct shape, because at the + top level `node` precedes `development` and would win. `./frames` server + does not (its sink bundles `_SOLID_DEV_`-gated runtime code, all stripped). +4. **The server facade has no diagnostics channel.** `server/index.ts:130`: + `export const DEV = undefined;`. It imports only `$REFRESH` from + `@solidjs/signals`; `emitDiagnostic`, `DiagnosticEvent`, `DEV.diagnostics`, + `attrHooks` do not exist on the server. It does have an owner tree + (`getOwner`, `signals.ts:246`) and a component wrapper + (`component.ts:65`) — the two things `ownerPath` needs. +5. **Behavioral gates are among the dead ones.** The late-header write is + spec'd to _throw_ in dev and `console.error` + no-op in prod + (`server.ts:4593`, #2982, pinned by + `test/server/dist-server-artifact.spec.tsx`). Today developers never see + the throw. Turning the dev build on changes what Vite dev SSR does on that + bug — that is the point, and it is a visible behavior change to announce. + +## Non-goals + +- No production observability, no observe build, no `attrHooks` on the server + (sketch §3, §9.4). This plan is the dev half only. +- No new server _verdicts_ (waterfalls, boundary timing). Those need the + server hook surface (Phase 3 below is scoping only). +- No change to what `dist/server.js` (prod) does, except that `solid-js`'s + prod server build gains the `replaceDev(false)` it should already have. + +## Phases + +### P0 — Build plumbing (the actual "server dev build") + +**`solid-js`** (`packages/solid/rollup.config.js`, `package.json`): + +- Add `dist/server.dev.{js,cjs}` from `src/server/index.ts` with + `replaceDev(true)`. +- Add `replaceDev(false)` to the existing prod server build. Today it has no + replace because the source has no gates; the moment P1 adds one, an + un-replaced truthy string literal would take the dev branch in prod — the + exact #2982 failure `@solidjs/web` already hit. +- Exports: nest `development` under `worker`, `deno`, `node` (copy the + `./server-functions` shape). Do **not** rely on the top-level `development` + key; `node` matches first. + +**`@solidjs/web`** (`packages/web/rollup.config.js`, `package.json`): + +- Add `dist/server.dev.{js,cjs}` from `src/index.server.ts` with + `replaceDev(true)`; same external list as the prod server build. +- Add `frames/dist/server.dev.{js,cjs}` likewise; nest `development` under + the server conditions of `./frames` and add it to `./frames/server`. +- `./storage`, `./serialization`: no gates today; leave until one appears. + +**Guards**: + +- Mirror `dist-server-artifact.spec.tsx` for the dev artifact: the late + header write **throws** from `dist/server.dev.js`. This pins that the + replace actually happened (a string scan cannot — folding erases the + marker either way, as the existing comment notes). +- A resolution test: with conditions `["node", "development", "import"]`, + `solid-js` and `@solidjs/web` both resolve to `*.dev.js`; with + `["node", "import"]` both resolve to prod. Cheap to write with + `import.meta.resolve` or `resolve.exports`, and it catches the + key-ordering trap permanently. +- Size: server artifacts are not in `scripts/size/` scenarios (browser-only). + No cap changes. Note it in the changeset anyway. + +**Changeset**: `solid-js` + `@solidjs/web`, patch (prerelease). Call out the +dev-SSR behavior change from fact 5. + +**P0 status (2026-09-07): done as specified**, with these notes: + +- `packages/web/frames/package.json` (the nested subpath stub, not just the + root `exports`) also needed the `development` condition; it was not in the + list above. `server-functions/package.json` was the model. +- Resolution test spawns Node with `--conditions` and reads + `import.meta.resolve` rather than reimplementing the exports algorithm — + Node's resolver is the reference. It covers all five server entries + (`solid-js`, `@solidjs/web`, `./frames`, `./frames/server`, + `./server-functions`) under `node`, `worker`, and `deno`, with and without + `development`, and requires the `link` build step (self-symlink) to have run. +- Not in the list above, and the first thing the dev artifacts needed to be + _for_ anything: both server entries hard-coded their public dev flags + (`solid-js` `export const DEV = undefined`, `@solidjs/web` + `export const isDev = false`), so a dev artifact was internally dev and + externally prod. Both now gate on `"_SOLID_DEV_"` like the client entries; + `solid-js`'s server `DEV` re-exports signals' object, which is the channel + P1 emits into. The export-parity test asserted the old `undefined`; it now + asserts identity with the client's `DEV`. +- Artifact check: `@solidjs/web` `dist/server.dev.js` is +5.5 KB over prod + (the 26 gates live; late header throws vs `console.error`s; `isDev` true). + `solid-js` `dist/server.dev.js` differs from prod only in `DEV` — no other + gates in `src/server/` until P1. +- Tests: web `test/server/dist-server-dev-artifact.spec.tsx` (late header + throws, `isDev === true`), `dist-server-artifact.spec.tsx` (+`isDev === +false`), `exports-server-conditions.spec.tsx`; solid + `test/server/dist-server-artifact.spec.ts` (`DEV` undefined in prod, is + signals' object in dev, same export surface). Full `@solidjs/web` + (712/764/165), `solid-js` (588), and type tests green. +- Naming: the server dev builds made the legacy bare `dist/dev.js` names + ambiguous (dev of which entry?), so the three remaining ones were renamed + to the `.dev.*` convention the rest of the repo already used — + `solid.dev.*`, `web.dev.*`, `universal.dev.*`. Signals keeps `dev.js` vs + `prod/` (chunked dir for the mangle pass; a restructure, not a rename). + The resolution test now pins the client pairing under `browser` too. +- `@solidjs/signals`' `require` branch had no `development` condition + (`dist/node.cjs` is `__DEV__: false` only), so CJS hosts loading + `server.dev.cjs` got signals' prod object and `DEV` came back `undefined` — + a dev artifact lying about the one export P1 will emit through. Fixed in + the same PR: `dist/node.dev.cjs` (unmangled twin of `dev.js`), selected by + `require.development`. The resolution test walks the CJS hops + (`@solidjs/web` → `solid-js` → `@solidjs/signals`) and pins all three + flipping together; a signals dist test pins `DEV` per CJS artifact. +- `solid-js#test` now depends on `solid-js#build` in `turbo.json` — it had + no dist-based tests until this work, so it was the one package whose test + task didn't wait for its own build. + +### P1 — A server diagnostics channel + +Decision: **reuse `@solidjs/signals`'s channel, do not fork it.** + +- The server facade imports `DEV` (and the `emitDiagnostic` / + `DiagnosticEvent` types) from `@solidjs/signals`, every use behind + `"_SOLID_DEV_"` so the prod server build folds it out and never touches + `DEV` (which is `undefined` in signals' prod build). On node in dev, + signals resolves to `dist/dev.js` via its top-level `development` condition + (no `node` key ahead of it in signals' exports — verified), so the channel is + live exactly when the server dev build is. +- Payoff: `DEV.diagnostics.subscribe/capture` and therefore + `@solidjs/diagnostics`'s `captureArtifact` work around `renderToStream` / + `renderToString` with **no new fixture code**. Server findings land in the + same JSONL artifact and the same budgets as client ones. This is the + "one mechanism, N front-ends" rule applied to the server. +- `server/index.ts` stops exporting `DEV = undefined` and re-exports the + signals `DEV` under the same `_SOLID_DEV_` gate the client entry uses, so + `import { DEV } from "solid-js"` means the same thing on both sides. +- `ownerPath`: the server component wrapper labels owner roots `` the + way the client wrapper does on `diagnostics-expansion` + (`packages/solid/src/client/core.ts:213`). Coordinate with that branch — + land after it, reuse its label helper rather than duplicating. +- Server-side `reportDiagnostic` (console face): the branch introduces one in + signals core; the server uses it as-is. Until the branch lands, emit + + `console.warn` as the client does on `next`. + +### P2 — Convert the existing sites to codes + +Every server `console.warn` that is a diagnostic becomes +`emitDiagnostic({ code, kind, severity, message, ... })` + report, behind +`"_SOLID_DEV_"`. Proposed codes (kinds reuse the existing `DiagnosticKind` +set plus `"ssr"` and `"head"`; add both to the union): + +| Today | Code | Severity | Notes | +| -------------------------------------------------------------------------- | -------------------------------- | -------- | ---------------------------------------------------------------------------------- | +| `[SERVER_WRITE]` (`signals.ts:677–696`) | `SERVER_WRITE` | warn | Text already has the bracket; make it real. Becomes dev-only (decision D1). | +| `ssrSource: "client"` outside `` (`signals.ts:519`) | `ASYNC_OUTSIDE_LOADING_BOUNDARY` | warn | Same code as the client; `data.side: "server"`. | +| Nested `` in `renderToString` (`flow.ts:357`) | `REVEAL_IN_RENDER_TO_STRING` | warn | | +| lazy asset failures (`component.ts` ×4, `server.ts:~548`) | `LAZY_ASSET_UNMAPPED` | warn | Five message texts, one condition. `data.id`, `data.reason`. | +| `registerAsset("preload")` validation (`server.ts:656–752`) | `PRELOAD_DESCRIPTOR_INVALID` | warn | `data.field`. | +| `useHead` non-head tag / bad attribute / eval error (`server.ts:833–1306`) | `HEAD_TAG_INVALID` | warn | `data.reason`. | +| "Unrecognized value. Skipped inserting" (`server.ts:4407/4471`) | `UNRECOGNIZED_INSERT_VALUE` | warn | Client has the same text in `client.ts`; share the code. | +| Late header write (`server.ts:4588–4594`) | `LATE_HEADER_WRITE` | error | Emit, then keep the existing dev throw / prod no-op. `data.header`. | +| `onError` fired, response still completed (`server.ts:1656`) | `SSR_RENDER_ERROR_CONTAINED` | warn | New. `ownerPath` of the failed boundary; `data.abandoned` if `abandonSubtree` ran. | +| `devCheckRange` (frames client) | `FRAME_MARKER_CORRUPTED` | error | Client-side, but same table so the frames pair is complete. | + +Not converted: `hydration.ts:183/190` `failRender ?? console.error` (error +containment; the structured face is `SSR_RENDER_ERROR_CONTAINED` above). + +Update `documentation/solid-2.0/08-dev-diagnostics.md` (server section) and +`packages/solid/skills/reactivity-diagnostics/SKILL.md` with the new codes +and repairs. The `DiagnosticCode` union in signals `dev.ts` grows +accordingly — the server imports the type, so the codes live in one place. + +### P3 — Scope the server hook surface (design only) + +Not implementation. Answer the sketch's §12 question: the server has no +re-runs, so `AttributionHooks` mostly does not apply. What does: + +- `flightStart` / `asyncEnd` per boundary await (`hydration.ts:176–317`) — + substrate for `SSR_BOUNDARY_WATERFALL`. +- `holdStart` / `transitionSettled` analog: boundary discovered → settled → + revealed (sink `reveal`). +- `write` — already the `[SERVER_WRITE]` site. + +Output: a short section appended to the sketch choosing between (a) the same +`AttributionHooks` interface with a server engine that implements the +applicable subset, or (b) a smaller `ServerHooks` interface. Leaning (a) for +type/tooling reuse, with unused members no-op. + +### P4 — `@solidjs/diagnostics` server scenario + +One test: `captureArtifact(() => renderToStream())` on the dev server +build asserts a seeded `HEAD_TAG_INVALID` and a seeded `SERVER_WRITE` appear +in `artifact.diagnostics` with `ownerPath`. Proves P1's promise end to end and +becomes the contract test for server codes. + +## Decisions (resolved 2026-09-06) + +- **D1 — Server diagnostics are dev-only.** The 8 `solid-js` server warnings + fire in prod today by accident of having no gate, not by design. All + diagnostics move behind `"_SOLID_DEV_"`, matching the client. + `[SERVER_WRITE]`'s "will become an error" is a deprecation notice — a dev + concern. Error containment (`failRender ?? console.error`) is not a + diagnostic and stays as is. +- **D2 — Condition name is `development`**, nested under `worker`/`deno`/ + `node`, exactly as `./server-functions` already does. +- **D3 — Land order: P0 now, independent of `diagnostics-expansion`.** P1/P2 + land after that branch merges and reuse its `ownerPath`, `reportDiagnostic`, + and component-label helper rather than duplicating them. + +## Risks + +- **Does the `development` condition reach server packages in Vite dev SSR?** + Vite's `ssr.resolve.conditions` include `development|production`, but + _externalized_ SSR deps are resolved by Node, which knows nothing of + `development` unless `ssr.resolve.externalConditions` includes it or the + packages are `noExternal`. `vite-plugin-solid` sets `noExternal` for the + solid packages (to honor the `solid` condition) — verify this in the plugin + repo before claiming the dev build is reachable. **Canary:** + `@solidjs/web/server-functions` already has a dev variant; if it resolves to + `server.dev.js` in a Vite dev SSR request today, the new ones will. If it + does not, that is a pre-existing bug this plan surfaces, and the fix is in + the plugin (externalConditions or noExternal), not here. +- **Non-Vite servers** (plain Node, Deno, workers in dev) need + `--conditions=development` or the runtime equivalent. Document it; it is + the same story as any dual-build package. +- **Mixed resolution**: `@solidjs/web` server externalizes `solid-js`; the + host resolves each package independently. Both must get `development` + together. The resolution test in P0 pins this for the two packages; the + plugin is responsible for passing the condition. +- **Dev SSR behavior change** (fact 5): late header writes throw in dev after + P0. Correct, but users who never saw the bug will see a crash. Changeset + and migration note. +- **Test suite already runs dev branches** (source, truthy literal), so P0 + changes nothing about test coverage of the gates — but it also means the + suite never exercised the _prod_ path of `solid-js`'s server entry either. + The artifact tests are the only guard; write them. + +## Related + +- Sketch: `documentation/proposals/production-observability-sketch.md` §3.0 + (wiring vs checks), §9 (server), §10.2–10.4 (server codes), §12 (open + questions this plan answers or scopes). +- Existing pattern: `packages/web/rollup.config.js` server-functions dev + build (`server-functions/dist/server.dev.*`) and its nested `development` + exports. +- #2982 and `packages/web/test/server/dist-server-artifact.spec.tsx` — the + artifact-level guard this plan copies for the dev variant. diff --git a/documentation/proposals/production-observability-sketch.md b/documentation/proposals/production-observability-sketch.md new file mode 100644 index 000000000..e736c554f --- /dev/null +++ b/documentation/proposals/production-observability-sketch.md @@ -0,0 +1,740 @@ +# SKETCH: Production observability on the attribution substrate + +Status: SKETCH ONLY. Nothing here is implemented. Drafted 2026-09-06 to give a +concrete shape to the "observability-vendor consumption" track that +`documentation/plans/agent-diagnostics-plan.md` deliberately keeps separate +from `@solidjs/diagnostics`. The intended first external consumer is Sentry; +the design must not depend on that. + +Related work this builds on (all dev-only today): + +- `next`: `packages/signals/src/core/attribution.ts` (engine), + `attribution-hooks.ts` (the core's hook surface), `dev.ts` + (`DEV.diagnostics`, `DiagnosticEvent`), `packages/diagnostics/` (artifact, + budgets, assertions). +- `diagnostics-expansion` branch (in flight, not yet on `next`): write + provenance (`ChangeOrigin`, `InteractionRef`, `withInteraction`), transition + holds (`HoldEvent`, `SILENT_HOLD`), `feedback()` tables + (`FeedbackSource`, `FeedbackInteraction`), `ownerPath` on every event. + Types referenced below are that branch's; expect drift until it lands. + +--- + +## 1. The claim + +Every vendor framework integration today is lifecycle-shaped: React +`ui.react.mount/render/update`, Vue `ui.vue.mount/update`, Svelte +`ui.svelte.init/update`, Angular `ui.angular.init`. Plus framework-agnostic +INP attribution via Long Animation Frames ("this script blocked N ms"). The +ceiling is _which component / which script_. None of it can say _why_, whether +the work changed anything, or what the user did to pay for it. + +Solid's runtime is the dependency graph, so the attribution engine reads +causality off structure rather than inferring it: + +- **Per re-run**: cause chain down to the root write, self/total time, + `changed: false` (waste), dependency delta, posture (plain / transition / + optimistic) so speculative work is never blamed. +- **Per root write**: provenance — `click on button#next "Next →"`, `effect +"syncTitle"`, `action "save"`, an async landing, or `external` — with nested + frames inheriting the interaction. +- **Per hold**: a write parked behind async, measured from the user's event, + with a _proof_ of whether the screen acknowledged it (`acknowledgedBy`, + `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`, + `WIDE_SCOPE_DEPS`, `SILENT_HOLD` — stable codes, `ownerPath`, and message + text that names the fix. + +The narrative: **observability that explains, not measures.** One vocabulary +from the dev console, to CI budgets (`@solidjs/diagnostics`), to a production +issue — the same code, the same repair text. + +This doc is about the third leg: what it takes to get these facts out of a +production build and into a vendor, without coupling the runtime to any vendor. +§3.0 measures how much of the wiring is shared between dev and prod; §9 covers +the server side (where the vendor gap is server actions and streaming +boundaries); §10 catalogs new diagnostics the exercise surfaced, tagged by +whether they need dev checks or ride the observe wiring. + +--- + +## 2. Hard constraints (from the code as it stands) + +1. **Everything is dev-gated.** `DEV` is `undefined` in `dist/prod` + (`packages/signals/src/core/dev.ts`); `attrHooks` call sites sit behind + `__DEV__` and fold out of prod, verified byte-identical by the size + scenarios (`scripts/size/.size-limit.js`). `@solidjs/web`'s interaction + wrapping at the two dispatch sites is behind `"_SOLID_DEV_"`. + `captureArtifact` throws on a prod build by design. +2. **Bundle size is budgeted and ratcheted.** Any prod-resident byte needs an + audit note. The established win is relocation into pay-for-use modules, + not golf (`signals.mdc`). +3. **Hook calls may not sit inside `try`** (rollup `tryCatchDeoptimization` + re-couples the engine into prod — `attribution-hooks.ts` header, #2883). + Any new prod-facing site inherits this rule. +4. **Names are the weak link.** Chains and `ownerPath` are only as good as + `_name` population; the plan already flags compiler name emission as a + pre-P3 risk. Prod adds minification on top. Internal fields are mangled + (`scripts/mangle-props.mjs`). +5. **Vendor-neutral by policy.** The plan: "Event/wire formats are + vendor-neutral (OTel-shaped where spans make sense). Observability-vendor + consumption is a separate track and must not appear in this package." + `attribution.ts` states the pattern: _one mechanism, N front-ends_ — a + vendor SDK is one more implementer of `AttributionHooks` / subscriber to + the event feeds, never a fork of the engine. + +--- + +## 3. Where the facts can leave the process + +### 3.0 How much wiring is shared (measured 2026-09-06) + +The runtime gate already exists: `attrHooks` is `null` until `enable()` +installs an engine, and every core site is the same one-liner, +`if (__DEV__ && attrHooks !== null) attrHooks.x(...)`. So dev and any prod +observability share **100% of the hook surface**; the only question is whether +the sites survive the build. The surface is small and well-delineated: + +- **Core hook sites (`@solidjs/signals`)**: 16 on `next` (`core.ts` 8, + `async.ts` 6, `store.ts` 2); **29 on `diagnostics-expansion`** (adds + `scheduler.ts` 6 — holds/settle/merge, `action.ts` 4 — step start/end, + `effect.ts` 3 — effect run start/end). All outside `try` per the #2883 rule. +- **Names**: 5 `_name` assignment sites (`core.ts` ×4, `store.ts` ×1) — + needed for anything to be legible. +- **Edge counters**: 2 sites (`graph.ts` `noteGraphLink` / `unnoteGraphLink`) + maintaining `_subCount`/`_depCount` — needed by `WIDE_WRITE` and the + always-on fan-out warnings. +- **Web runtime (`@solidjs/web`)**: 3 `withInteraction` wrap sites + (`attachDelegatedEvent`, and the two `addEvent` branches) behind + `"_SOLID_DEV_"`. +- **Component labels (`solid-js`)**: 1 site (`client/core.ts` sets + `owner._name = ""`), the source of `ownerPath`. + +Total: **~40 sites** that constitute the observability wiring. Contrast with +the **~140 `__DEV__` sites** in signals (`core.ts` 31, `scheduler.ts` 19, +`signals.ts` 15, `async.ts` 10, `effect.ts` 10, …) — the rest are dev +_checks_ (strict reads, owner-scope writes, invariants, forbidden scopes, +console reporting) that prod observability does not need and must not ship. + +So the split is not "dev vs prod"; it is **wiring vs checks**. Today both +hide behind one flag (`__DEV__`). Separating them is a two-flag change +(`__DEV__` for checks, a second flag for wiring) touching ~40 lines, not an +architecture change. + +Size estimate for keeping the wiring in a bundle (to be measured, not +trusted): each site minifies to roughly `X!==null&&X.f(a,b)` — ~25–40 bytes +pre-compression; 29 sites + 5 name writes + 2 counter calls ≈ 1–1.5 KB +minified, plausibly **300–500 bytes brotli** given how compressible the +repeated shape is. Plus one string field per node (`_name`) at runtime. Small, +but not zero, and the prod cap is ratcheted for a reason. + +### 3.1 Gating strategies + +Three ways to decide whether the ~40 sites exist in what an app runs: + +**(i) Compile-time constant, separate published build** (`__OBSERVE__` +replaced by rollup like `__DEV__` today; export condition selects it). Default +`dist/prod` stays byte-identical. Cost: one more build flavor to maintain and +size-track; apps must opt in at the bundler. + +**(ii) Runtime gate only, single build** — the sites ship in `dist/prod` +guarded by the existing `attrHooks !== null` check; `enable()` installs the +engine (lazily imported so unobserving apps never load `attribution.ts`). +This is the "enable call with a gating global" shape. No opt-in friction; but +tree-shaking cannot remove a runtime check, so every app pays the bytes above +and the #2883 "prod folds every site out" invariant is abandoned. + +**(iii) App-defined constant, single build** — ship the sites guarded by an +_unreplaced_ global (`__SOLID_OBSERVE__`) that the app's bundler `define`s, +the way `process.env.NODE_ENV` worked for React. Folds out when the app +defines it `false`; present when `true`. Problems: an app with no `define` +gets a `ReferenceError` unless every site is `typeof __SOLID_OBSERVE__ !== +"undefined" && ...`, which no longer folds without the define and costs a +`typeof` per site; and it moves a library build decision into every app's +bundler config, which is the failure mode export conditions were adopted to +avoid. + +**Verdict:** (i) unless measurement shows (ii) is noise. (iii) is not +recommended. Rationale beyond bytes: (i) keeps the checks/wiring split +explicit in the build matrix and lets the observe flavor grow (e.g. keep +`ownerPath` labeling, keep `reportDiagnostic` for a console fallback) without +re-litigating the prod cap each time. If (ii) is ever chosen, the runtime +gate is already there — nothing about the engine changes; only the flag on +the sites. + +### Option A — Instrumented production build (recommended starting point) + +A third build flavor alongside `dist/dev` and `dist/prod`: `dist/profiling` +(name TBD; React's `react-dom/profiling` is the precedent users already know). + +- `__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` + 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 + `attachDelegatedEvent` and `addEvent` gate on `__OBSERVE__ || _SOLID_DEV_`. +- Resolved by an export condition (`"profiling"` / `"observe"`), the same + mechanism that already selects `development` vs `default`. Vite/Vinxi + config opts in; the default prod build is untouched and stays byte-identical + to today. +- Size: the observe build gets its own size-limit scenario and its own cap. + The regular prod cap does not move. This is the whole point of the flavor. + +Cost when enabled is the engine's real cost (frames per recompute, cause +collection, dep snapshots). That is why sampling is the prod posture (§5), +not "always on." + +### Option B — Promote the hook sites into the base prod build + +Strategy (ii) above. Simplest for consumers (no build opt-in), but every +Solid app pays the bytes and the checks, and the "prod folds every site out" +invariant that #2883 fought for is abandoned. Ruled out for a first cut; +revisit only if A proves the demand and the measured cost is noise. The +measurement is cheap: a scratch worktree that swaps `__DEV__` for a second +flag on the ~40 wiring sites and runs `scripts/size/` against both flavors. + +### Option C — No runtime change; vendor consumes `DEV` in dev only + +What exists today. Useful for the "dev console → Sentry local issues" demo but +does not touch production and so does not deliver the claim. Listed for +completeness. + +--- + +## 4. The wire: vendor-neutral event shapes + +Three feeds already exist as in-process subscriptions. The proposal is to +define serializable projections of each and let a vendor adapter translate +them. Shapes below are projections of the branch's types, not new concepts. + +### 4.1 Interaction span (from `FeedbackInteraction` + `HoldEvent`) + +The unit Sentry's INP module and Web Vitals UI already reason about. One span +per dispatch, children as below. + +``` +name: "click on button#next \"Next →\"" // InteractionRef type+target +op: "ui.solid.interaction" +start/end: InteractionRef.at → last effect that traces back to it +attributes: + solid.interaction.type "click" + solid.interaction.target "button#next \"Next →\"" + solid.runs 340 // re-runs caused, synchronous + solid.self_ms 22.4 // summed self-time of those runs + solid.wasted_ms 21.9 // runs whose value did not change + solid.held_ms 712 // time its writes sat behind async + solid.silent_ms 712 // held with no acknowledgment + solid.owner_path ["", ""] // of the handler's owner +children: + op "ui.solid.hold" one per HoldEvent (see 4.2) + op "ui.solid.rerun" only for runs above a per-span threshold + (selfMs ≥ N or changed === false ∧ selfMs ≥ M); + never every run — that is the noise vendors + already tell React users to turn off. +``` + +The two INP failure modes are two attribute groups on one span: long flush +(`runs`/`self_ms`/`wasted_ms`) and silent wait (`held_ms`/`silent_ms`). This +is the row `feedback().interactions` already computes; the span is its +per-dispatch form. + +### 4.2 Hold span (from `HoldEvent`) + +``` +name: "held: page (1 → 2) on posts" +op: "ui.solid.hold" +start/end: interaction.at (or first parked flush) → transition completion +attributes: + solid.hold.writes [{ name: "page", prev: "1", value: "2" }] + solid.hold.blockers ["posts"] + solid.hold.acknowledged_by ["isPending:posts"] // [] = silent + solid.hold.painted 0 // paintedDuringHold + solid.hold.action false + solid.hold.flushes 3 +``` + +### 4.3 Finding (from `DiagnosticEvent`) + +A finding is an _issue_, not a span: it has a stable identity and recurs. + +``` +code: "SILENT_HOLD" // DiagnosticCode — the fingerprint root +kind: "responsiveness" +severity: "warn" +message: "...the repair text as emitted..." +owner_path: ["", "", "effect"] +node_name: "page" +data: { ...code-specific structured fields, already on the event } +interaction: ChangeOrigin | undefined // when the finding traces to one +``` + +Fingerprint: `code + ownerPath.join("›") + nodeName`. That groups every +occurrence of "the pager holds silently" into one issue regardless of session +or minified identifiers, which is exactly what vendors' issue grouping wants +and what LoAF script attribution cannot give. + +Severity mapping is the engine's own tiering: `info` never becomes an issue +(advisory only; depth-2 waterfalls, sub-`warnMs` holds); `warn` opens a +performance issue; `error` codes are dev-only and do not exist in the observe +build. + +### 4.4 Rerun record (from `RerunEvent`) + +Serialized as `Omit` — `@solidjs/diagnostics` already +defines exactly this projection (`RerunRecord`). Attached to the interaction +span only above thresholds (4.1); otherwise folded into the span's aggregates. + +### 4.5 Cause chain (from `ChangeRecord`) + +Kept as a nested structure on the finding/rerun (`causes[]` with +`origin`), depth-capped at the engine's 10. The `prev`/`value` previews are +the PII surface — see §6. + +--- + +## 5. Sampling and cost posture + +- **Per-session enable/disable**, decided by the consumer: the adapter calls + `attribution.enable({ log: false, ... })` for the sampled fraction and never + touches it otherwise. Unsampled sessions pay one null check per site. This + maps 1:1 onto `tracesSampleRate` / `interactionsSampleRate` on the vendor + side. +- **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`, + `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. +- **Stacks stay off** (`stacks: false`) in prod. `ownerPath` is the + location; stacks are the dev affordance. + +Overhead measurement is part of the deliverable, not an afterthought: the +observe build needs a benchmark scenario with the engine enabled (frames, +cause collection, dep snapshots) so the "sampled fraction" recommendation is +a number, not a vibe. + +--- + +## 6. Names and PII + +**Names.** Three sources, in order of preference: the `name` option on +primitives, `store.path` (automatic while the engine is active), and the +`` labels the dev component wrapper puts on owner roots. In the +observe build the component wrapper must keep labeling (today it is dev-only); +the compiler must emit `name` for user memos/effects/signals it can see (open +pre-P3 item in the plan). Minification: `name` values are string literals +and survive; the _component function_ name does not unless the wrapper +captures it at definition time — which is what the label does. If that is not +enough, the answer is a name map emitted at build time, which is the same +shape as source maps and vendor component-annotate plugins; do not invent a +second mechanism. + +**PII.** Two fields carry user data: `ChangeRecord.prev/value` (and +`HeldWrite.prev/value`) previews, and `InteractionRef.target` text content +(`describeEventTarget` includes up to 30 chars of `textContent`). Posture: + +- The engine keeps producing them (they are what makes dev output readable). +- The _adapter_ owns scrubbing, with a documented default: previews of + strings are dropped or hashed unless the consumer opts in; numbers, + booleans, and type/length previews (`Array(12)`, `[Object]`) pass. Target + text is kept for `button`/`a` (labels), dropped for anything else. +- Vendors already have this control surface (`dataCollection`, + `beforeSend`); the adapter plugs into it rather than duplicating it. + +--- + +## 7. The adapter contract (vendor side) + +What a vendor package (`@sentry/solid` or anyone) implements. Deliberately +small; everything else is the engine's. + +```ts +interface ObservabilityAdapter { + // Called by the app at startup on an observe build, after the vendor SDK + // is initialized. Decides sampling, calls attribution.enable/disable. + install(dev: Dev, opts: { sample: () => boolean }): () => void; +} +``` + +Inside `install`, the adapter subscribes to the three feeds: + +- `dev.attribution.subscribe(rerun => ...)` — aggregate into the current + interaction span (keyed by `rerun.interaction`), emit rerun children above + thresholds. +- `dev.diagnostics.subscribe(event => ...)` — `warn` → finding (4.3). +- Holds: today only via `dev.attribution.holds()` polling; a `holdEnd` + subscription (`subscribeHolds`) is a small engine addition and should be + made before the first adapter exists rather than after. + +Interaction boundaries: the web runtime's `withInteraction` already brackets +dispatch. The adapter does not wrap events itself — doing so would double-count +and would miss the branch's inheritance rules (post-`yield` action steps, +caused effects, launched flights all carry the interaction). It reads +`RerunEvent.interaction` / `HoldEvent.interaction` and groups. + +Anything the adapter needs that isn't on those feeds is an engine gap to +fix once, for every consumer — not something to compute vendor-side. + +--- + +## 8. What is Solid's to build vs. the vendor's + +Solid (this repo): + +0. **Server dev build first** — `documentation/plans/server-dev-build-plan.md`. + There is no server dev build today: `solid-js`'s server entry has no + `_SOLID_DEV_` replace and fires its warnings unconditionally in prod; + `@solidjs/web`'s server entry has 26 dev gates that are stripped in the + only artifact that ships. Every server item below needs a dev channel to + exist before a prod one can. +1. Observe build flavor + export condition for `@solidjs/signals`, `solid-js`, + `@solidjs/web` (§3A). Size scenario and cap for it. +2. `subscribeHolds` on the engine; confirm every feed is subscribable, not + poll-only. +3. Component-root labeling in the observe build; compiler `name` emission for + user primitives (already a plan item). +4. Serializable projections as exported types (`RerunRecord` exists in + `@solidjs/diagnostics`; the finding/hold/interaction projections should + live next to it — the plan already says protocol types publish from + there). +5. An enabled-engine overhead benchmark. +6. A reference adapter that writes JSONL / OTel spans to stdout — proves the + contract with zero vendor code and doubles as the test fixture. +7. Server side (§9.4): trace context on the request event + `Server-Timing` + emission; default `wrapInvocation` span for server functions; boundary + spans on streaming SSR; a server-side `emitDiagnostic` channel so the + §10.2/10.3 findings have somewhere to go. +8. The dev-only codes in §10 that pay off before any observe build exists + (§12 last bullet). + +Vendor (e.g. `@sentry/solid`): + +1. `install()` per §7, sampling wired to their rates. +2. Span/issue mapping per §4, fingerprinting on `code + ownerPath + nodeName`. +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 + 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 + for an agent. + +--- + +## 9. Server side + +### 9.1 What vendors offer today + +- **Request-level auto-instrumentation** for meta-frameworks: SvelteKit + `load` + `handle`, Nuxt server routes, Remix loaders, Next.js API routes and + Server Components. One span per request; child spans for outgoing fetch/DB + via OTel auto-instrumentation. +- **Distributed tracing** across the SSR boundary by header: `sentry-trace` + + `baggage` (optionally W3C `traceparent`) in on requests; out to the browser + via `` tags in the HTML or the `Server-Timing` header (Remix moved to + `Server-Timing` in 10.45; Vercel's CDN stopped stripping `Server-Timing` on + 2026-08-10; Grafana Faro reads `traceparent;desc=` from it). The browser SDK + parents its `pageload` span under the server transaction. +- **Server actions are the acknowledged gap.** Next.js server actions emit no + OTel span; Sentry requires manual `withServerActionInstrumentation(name, +{ headers, formData })` per action and has said auto-instrumentation would + need a Turbopack-level transform they will not build. Unwrapped actions show + up as anonymous POSTs with no trace continuity. +- **Framework-native server diagnostics**: React 19.2 Performance Tracks add + "Server Components" and "Server Requests" lanes in Chrome DevTools — dev + builds only, not in profiling builds, visual only (no verdicts). Waterfall + detection is "look for the stair-step." +- **Server-side performance-issue detectors** (Sentry): N+1 DB queries, + consecutive DB queries, slow DB query, endpoint/function regressions. All + span-arrangement heuristics over the trace; none know what a streaming SSR + boundary is. + +### 9.2 What Solid's server runtime already exposes + +Verified against `next` (file:line as of 2026-09-06): + +- **Request context**: `provideRequestEvent` (`@solidjs/web/storage`, + `storage/src/index.ts:34`) on `AsyncLocalStorage`; `getRequestEvent()` / + `peekRequestEvent()` (`server.ts:4510–4537`); augmentable + `RequestEventLocals` (`server.ts:257`) — the natural carrier for a trace id. + **No trace id or header propagation exists today.** +- **Server functions** (`server-functions/src/server.ts`): a config-level + `wrapInvocation` hook (`:272`, `:469`) around every invocation with + `{ id, args, event, request?, direct }` — direct SSR calls share it with + `direct: true`. Plus `transformResult`, `collectFlightData`, CSRF, codec. + Per-call identity is the _function id_, not a request/trace id. This is the + seam that makes server-function spans automatic, where Next.js needs manual + wrapping: the runtime owns dispatch, decode, invoke, encode. +- **Streaming SSR** (`server.ts`): `onError` (`:1449`, `:1656–1660`), + `onCompleteShell` (`:2359`), `onCompleteAll` (`:1768`); sink methods + `shell/fragment/reveal/data/asset` are single call sites for boundary + flush/reveal; `context.hold()` for live work keeping the response open; + fragment registry with `abandonSubtree` on errored fragments (`:1958`). +- **HTTP head**: `StatusLedger` / header ledgers (`index.server.ts:247–379`) + with declare/retract semantics until commit; late header write after the + head flushed is a dev throw / prod `console.error` + no-op + (`server.ts:4588–4594`, #2982); header value cap + `RESPONSE_HEADER_VALUE_LIMIT = 4096` (`response.ts:149`). +- **Server reactive facade** (`packages/solid/src/server/`): per-`Loading` + boundary discovery → await → settle/reveal (`hydration.ts:176–317`); + `[SERVER_WRITE]` once-per-category warnings for signal/store/optimistic + writes on the server (`signals.ts:677–696`). **No exported waiting-count or + per-boundary timing.** +- **Frames** (`packages/web/frames/`): `renderToFrameStream` / + `renderServerComponent` wrap `renderToStream` (inherit `onError`, shell, + settle); client applies records and runs marker-integrity checks in dev + (`frame-client.ts:2469` `devCheckRange` — missing end markers, CDN/minifier + stripped comments). +- **Hydration** (`packages/web/src/client.ts`): mismatch throw (`:1787`), + tag/structure warnings (`:1795–1870`), orphaned nodes (`:1716`), preload + failure → client fallback (`:1763`), `createElement`-during-hydration + (`:278`). Asset gate `$dfc` releases on load _or_ error with no timeout + (`:1062`). Multi-root pending-boundary registry (`solid/client/hydration.ts` + `:204–258`, #2917). **No time-to-hydrate measurement.** + +### 9.3 Detected internally, not surfaced + +Failure modes the server runtime already handles but reports nowhere +structured — each is a candidate finding (§10): + +- `await renderToStream(...)` **never rejects**; render errors go to + `onError` and the promise still resolves HTML (`server.ts:1579–1584`). +- Stream **disconnect/abandon** → silent wind-down (`:1634–1644`, + `:1698–1714`). +- `abandonSubtree`: descendants resolve with `undefined` data so the client + re-renders — no signal that a subtree was abandoned or why (`:1950–1970`). +- Seroval **post-flush writes silently dropped** (`:1893–1919`). +- Server-function encode failure after head commit → in-band error trailer, + status unchanged (`server-functions/src/server.ts:2759–2786`); prod + **sanitizes plain throws** (message lost unless the app maps it in + `wrapInvocation`). +- Late header write in prod: `console.error` + dropped header — the response + went out wrong and nothing records it. + +### 9.4 What to build server-side + +Ordered by leverage: + +1. **Trace id on the request event.** Read `traceparent` (W3C) and, if + present, `sentry-trace`/`baggage` in `provideRequestEvent` or a tiny + `@solidjs/web/storage` helper; stash on `event.locals`; expose + `getTraceContext()`. Emit it out via `Server-Timing: +traceparent;desc="00-…"` at head commit (`commitResponseStub` / + `createSSRResponse`) — the header, not a `` tag, because frames and + server-function responses have no ``. This is the one piece that + turns every span below into one trace with the browser's interaction span + (§4.1 gains `trace_id`). +2. **Server-function spans for free.** A default `wrapInvocation` in the + observe build that opens a span `rpc.solid.server_function` named by + function id, tagged `direct`, with decode/invoke/encode phases as + attributes, and links to the incoming trace. Errors captured with the + _unsanitized_ message on the server side (sanitization is for the wire, not + for telemetry). This is the "Next.js can't, Solid does" headline. +3. **Boundary spans on streaming SSR.** One span per `Loading` boundary from + discovery to reveal (`hydration.ts` settle → sink `reveal`), attributes: + depth, number of async sources awaited, bytes flushed, whether it was + shell or streamed. Server-side waterfalls become visible as nested boundary + spans whose _starts_ are serialized — the same `ASYNC_WATERFALL` verdict + the client engine already makes, run over server flights (see §10). +4. **Response head facts**: at commit, record status, header count, whether + any declaration was retracted, and any _late_ declaration as a finding + (`LATE_HEADER_WRITE`, §10) instead of a `console.error`. +5. **Frame lifecycle spans**: produce (`renderToFrameStream` → settle) on the + server; apply/bind/dispose on the client, parented to the interaction that + requested the frame. Marker-integrity failures become findings with the + frame id and the stripped marker, so "the CDN rewrote our HTML" is an issue + with a fingerprint, not a support ticket. +6. **Hydration timing**: `render()`/`hydrate()` start → last pending boundary + hydrated (the #2917 registry already tracks the count) as a client span + parented to `pageload`, attributes: roots, boundaries, mismatch findings. + +--- + +## 10. Diagnostics catalog: additions surfaced by this exercise + +Vocabulary: **dev** = check that needs `__DEV__` (throws/warns, may be +expensive, may be wrong in prod); **wiring** = fact the observe build can +carry with the ~40-site surface (§3.0) plus the server hooks in §9.2; +**prod verdict** = engine-side verdict computable from wiring alone. Codes +are proposals; thresholds follow the engine's tiering (`info` advisory, +`warn` issue). + +### 10.1 Client / reactive + +- `INTERACTION_LONG_FLUSH` — _prod verdict, new._ One dispatch's synchronous + re-run self-time exceeded a budget (default 50ms — the INP long-task bar). + The engine already computes `worstDispatchMs` per interaction; this is the + thresholded verdict over it, the client-side twin of `SILENT_HOLD`. Message + names the interaction, the top three scopes by self-time, and the + `wastedMs` share. Repair text points to `costs()`. +- `INTERACTION_NO_EFFECT` — _prod verdict, new._ A user interaction performed + root writes and caused **zero** effect runs and no hold: every write was + equality-swallowed or wrote to nothing observed. Not necessarily a bug + (idempotent toggles), so `info`. But repeated dispatches with no effect are + the rage-click precursor Sentry currently detects only from replay after the + fact; the graph sees it at the first click. +- `HOLD_LATEST_ONLY` — _prod verdict, promote from data._ `feedback()` + already tracks `latestOnly` (input showed, nothing said loading). Above + `warnMs` this deserves its own code rather than hiding in a table: the fix + is different from `SILENT_HOLD` (add `isPending`, not `latest`). +- `EFFECT_WRITE_CASCADE` — _dev check + prod verdict, new._ A write whose + `origin.kind === "effect"` caused a re-run of the effect that made it + (self-loop through the graph) or a chain of ≥3 effect-origin writes in one + flush. The provenance branch makes this cheap to detect; today it surfaces + only as the 100k-iteration infinite-loop throw. +- `ACTION_ESCAPED_TRANSACTION` — _dev check, new._ A write with + `origin.kind === "external"` whose enclosing frame is an action iterator's + microtask continuation — the documented `await`-not-`yield` escape. The + branch already classifies these as `external`; naming them is the missing + step. Message: "write to X inside action Y ran after an `await`; use + `yield` to re-enter the transaction." +- `STALE_OPTIMISTIC` — _prod verdict, new._ An optimistic value stayed + visible longer than a budget (default 10s) because its action neither + settled nor failed. Rides on `holdStart`/`transitionSettled` + the action + step hooks. This is the "stuck spinner" class no vendor sees as anything but + an eventual rage click. +- `ORPHANED_HOLD` — _prod verdict, new._ A transition held writes, then was + disposed (owner unmounted) before settling — the user navigated away + waiting. `holdStart` without `transitionSettled`/`transitionMerged` before + disposal. Attribute: `holdMs` at abandonment. Feeds "how long do users + wait before giving up" without a session-replay product. + +### 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 + 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. +- `SSR_RENDER_ERROR_CONTAINED` — _wiring, new._ `onError` fired and the + response still completed (the `renderToStream` never-rejects contract). + Today the app learns this only if it wired `onError` itself; in the observe + build it is a finding with `ownerPath` of the failed boundary and whether a + fallback rendered or the subtree was abandoned. +- `SSR_SUBTREE_ABANDONED` — _wiring, new._ `abandonSubtree` ran: fragment id, + descendant count, the error that caused it, and that the client will + re-render from scratch (a cost the server just shifted to the user). +- `SSR_STREAM_ABANDONED` — _wiring, new._ Sink threw (client disconnected / + proxy reset) mid-stream: bytes flushed, boundaries pending, hold count. + Distinguishes "user left" from "we were slow" when aggregated with + time-to-shell. +- `LATE_HEADER_WRITE` — _wiring, promote._ Already detected (`server.ts:4588`, + #2982); today a dev throw / prod `console.error`. Becomes a finding with the + header name and the `ownerPath` of the declaring scope. Prod behaviour + (drop) unchanged. +- `HEADER_DECLARATION_RETRACTED` — _wiring, new, `info`._ A status/header + 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 + 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. +- `SERVER_FN_LATE_FAILURE` — _wiring, new._ Encode failed after head commit + → in-band error trailer with a 200 status. Vendors see a 200; the finding + says it was a failure. +- `SERVER_FN_PAYLOAD_LARGE` — _prod verdict, new._ Encoded response or + decoded args over a budget (default 256 KB). The runtime already sits at the + encode/decode seam; Sentry has "Large HTTP Payload" for fetch but cannot + attribute it to a function id. +- `SERIALIZATION_POST_FLUSH_DROPPED` — _wiring, promote._ Seroval writes + after the stream closed are dropped silently (`server.ts:1893–1919`). Name + the node and the boundary; this is data the client never got. +- `SERVER_WRITE` — _dev check, exists._ Keep; add `ownerPath` (it currently + has none, so the offending component is unknown). + +### 10.3 Hydration / frames + +- `HYDRATION_MISMATCH` — _wiring, promote._ The throw at `client.ts:1787` and + the tag/structure warnings at `:1795–1870` become one structured finding: + expected/actual tag, `ownerPath`, marker id. Sentry's "Hydration Error" + issue type is React-shaped and reconstructs the diff from replay because + React gives it nothing structured; Solid can hand it the node. +- `HYDRATION_CLIENT_FALLBACK` — _wiring, promote._ Preload failure led to + client render (`:1763`): the asset URL and boundary. This is the + "hydration silently became CSR" event that currently shows only as a slower + LCP. +- `HYDRATION_ROOT_TIMING` — _span, new._ Per §9.4.6: roots, boundaries, + duration. Not a finding; the base measurement. +- `ASSET_GATE_STALLED` — _prod verdict, new._ `$dfc` gate not released within + a budget (default 10s): stylesheet neither loaded nor errored. Today the + boundary waits forever (`client.ts:1062`, no timeout). Attribute: href. +- `FRAME_MARKER_CORRUPTED` — _wiring, promote._ `devCheckRange` findings + (`frame-client.ts:2469`) with frame id and which marker is missing; + message already says "CDN/minifier/translator". In prod today the frame + fails with no attribution. +- `FRAME_STALE_DISCARDED` — _wiring, new, `info`._ A frame version arrived + after a newer one was applied and was discarded. Legal, but a high rate is + a request-ordering or caching problem. + +### 10.4 Always-on dev, unrelated to prod + +Surfaced while reading, worth adding to the dev catalog regardless of the +observe track: + +- `ASYNC_OUTSIDE_LOADING_BOUNDARY` on the server — the client warns; the + server facade's equivalent (`ssrSource: "client"` outside ``, + `signals.ts:519`) is a bare `console.warn` with no code. +- `LAZY_ASSET_UNMAPPED` — the dev messages at `server.ts:~548` and + `component.ts:123/284–298/380` about lazy modules missing from the asset + map / missing `$$moduleUrl` are three message texts for one condition. +- Frames/server `console.warn` sites generally: every one should route + through `emitDiagnostic` so `@solidjs/diagnostics` budgets can see them. + `emitDiagnostic` does not exist on the server side today (§9.2); the + server facade needs the same `DiagnosticEvent` channel, dev-gated the same + way. + +--- + +## 11. What this is not + +- Not component tracking. Solid components run once; mount/update spans + would be empty or wrong, and vendors already tell React users to disable + update spans for noise. The unit is the interaction, not the component. +- Not a devtools GUI. Same substrate, separate front-end (plan non-goal). +- Not a change to `dist/prod`. Option A leaves the default production bundle + byte-identical; opting in is a build decision, like source maps. +- Not vendor-specific. The Sentry mapping in §4/§8 is worked as the first + consumer because that is where the access is; the shapes are OTel-ish + spans and structured findings that any backend can take. + +--- + +## 12. Open questions + +- **Build posture decision (§3.1)**: separate observe flavor (i) vs. wiring in + base prod (ii). Blocked on one measurement: brotli delta of the ~40 wiring + sites in a scratch worktree. If it is under ~300 bytes the argument for (i) + is purely the #2883 invariant and build-matrix hygiene; if it is over ~1 KB + the argument is closed. +- Flag/condition naming: `__OBSERVE__` / `"observe"` vs `"profiling"` (React + familiarity) vs folding into `development` with the dev checks disabled by + an option. The last is tempting but makes "prod" mean two things. +- Whether hold spans should nest under the interaction span or be siblings + linked by id — depends on how the vendor's INP view attributes child time. +- **Server-side engine**: the server facade (`packages/solid/src/server/`) has + no `emitDiagnostic`, no `attrHooks`, and no `DEV` object. §9.4 and §10.2 + assume a server-side hook surface shaped like the client's. Whether that is + the same `attribution-hooks.ts` interface with a server engine, or a smaller + request-scoped one, is undecided — the server has no re-runs, so most of + `AttributionHooks` does not apply; flights, holds/settle per boundary, and + writes do. +- **Trace header policy**: read `traceparent` only (W3C), or also + `sentry-trace`/`baggage`? Reading vendor headers in the runtime is a + coupling; the alternative is `provideRequestEvent` accepting a + `traceContext` the host adapter extracted. Leaning: W3C in the runtime, + vendor headers in the adapter. +- Whether the reference adapter (§8.6) lives in `@solidjs/diagnostics` or a + new `@solidjs/observe`. Leaning diagnostics until a second consumer exists, + mirroring the plan's extraction triggers. +- Which of the §10 codes are worth doing _before_ the observe build exists, + as pure dev additions: `ACTION_ESCAPED_TRANSACTION`, `EFFECT_WRITE_CASCADE`, + `INTERACTION_LONG_FLUSH`, server `emitDiagnostic` routing (§10.4) all pay + off in dev alone. diff --git a/packages/signals/package.json b/packages/signals/package.json index f9dae40b0..f070c880b 100644 --- a/packages/signals/package.json +++ b/packages/signals/package.json @@ -34,6 +34,7 @@ }, "require": { "types": "./dist/types-cjs/index.d.cts", + "development": "./dist/node.dev.cjs", "default": "./dist/node.cjs" } }, @@ -41,7 +42,7 @@ }, "scripts": { "build": "npm-run-all -nl build:* && pnpm types", - "build:clean": "rimraf dist/dev dist/prod dist/node dist/dev.js dist/prod.js dist/node.cjs", + "build:clean": "rimraf dist/dev dist/prod dist/node dist/dev.js dist/prod.js dist/node.cjs dist/node.dev.cjs", "build:js": "rollup -c && node ./scripts/mangle-props.mjs dist/prod dist/node.cjs && node ./scripts/check-pure.mjs dist/prod", "types": "tsc -p tsconfig.build.json && node ../../scripts/sync-dual-types.mjs ./dist/types ./dist/types-cjs", "test": "vitest run", diff --git a/packages/signals/rollup.config.js b/packages/signals/rollup.config.js index cb0294ce9..db76556ee 100644 --- a/packages/signals/rollup.config.js +++ b/packages/signals/rollup.config.js @@ -6,7 +6,7 @@ import prettier from "rollup-plugin-prettier"; // consumed exclusively by bundlers, which can drop whole feature modules — // including their top-level GlobalQueue hook installs, which statement-level // shaking of a flat file can never remove (#2883) — and scope-hoist the rest -// back into one module. Dev and node stay flat single files: dev bundle size +// back into one module. Dev and node (prod + dev CJS) stay flat single files: dev bundle size // doesn't matter (and vitest's per-module SSR transform makes a chunked tree // ~2x slower in the flush hot path, poisoning CI benches), and CJS `require` // can't tree-shake, so a tree would charge unbundled SSR the per-module- @@ -95,5 +95,38 @@ export default [ parser: "typescript" }) ] + }, + { + // Dev CJS — the `require` twin of dist/dev.js, selected by the + // `development` condition on the `require` branch. Without it a CJS host + // that resolved solid-js's `dist/server.dev.cjs` would `require` the prod + // `dist/node.cjs` (`__DEV__` false) and get `DEV === undefined` from a dev + // artifact — a lie that goes unnoticed until something emits into + // `DEV.diagnostics`. Flat single file like node.cjs; not mangled (dev + // outputs never are — see build:js). + input: "src/index.ts", + output: { + file: "dist/node.dev.cjs", + format: "cjs", + exports: "named" + }, + plugins: [ + replace({ + __DEV__: "true", + __TEST__: "false", + preventAssignment: true + }), + typescript({ + declaration: false, + outDir: "dist", + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + verbatimModuleSyntax: true + }), + prettier({ + parser: "typescript" + }) + ] } ]; diff --git a/packages/signals/tests/dist-cjs-artifacts.test.ts b/packages/signals/tests/dist-cjs-artifacts.test.ts new file mode 100644 index 000000000..459bf9a23 --- /dev/null +++ b/packages/signals/tests/dist-cjs-artifacts.test.ts @@ -0,0 +1,35 @@ +/** + * Direct coverage of the built CJS artifacts — dist/node.cjs (prod, the + * `require` default) and dist/node.dev.cjs (the `development` condition on + * the `require` branch). The suite otherwise imports source under vitest's + * `__DEV__: true` define, so it cannot see whether each artifact's replace + * ran. `DEV` is the observable: defined in dev, `undefined` in prod. The dev + * CJS exists so a CJS host that resolves solid-js's `dist/server.dev.cjs` + * also gets a signals with a live `DEV.diagnostics` — one dev flag per + * process, not one per module format. Requires a prior build (the turbo + * `test` task depends on `build`). + */ +import { createRequire } from "node:module"; +import { describe, expect, test } from "vitest"; + +const require = createRequire(import.meta.url); + +describe("@solidjs/signals CJS artifacts", () => { + test("dist/node.cjs (prod) exports DEV as undefined", () => { + const prod = require("../dist/node.cjs"); + expect(prod.DEV).toBeUndefined(); + }); + + test("dist/node.dev.cjs exports a live DEV with the diagnostics channel", () => { + const dev = require("../dist/node.dev.cjs"); + expect(dev.DEV).toBeDefined(); + expect(typeof dev.DEV.diagnostics.subscribe).toBe("function"); + expect(typeof dev.DEV.diagnostics.capture).toBe("function"); + }); + + test("the two artifacts export the same surface", () => { + const prod = require("../dist/node.cjs"); + const dev = require("../dist/node.dev.cjs"); + expect(Object.keys(dev).sort()).toEqual(Object.keys(prod).sort()); + }); +}); diff --git a/packages/solid/package.json b/packages/solid/package.json index a71b59cd0..0ff5036ed 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -28,6 +28,16 @@ "exports": { ".": { "worker": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -41,11 +51,11 @@ "development": { "import": { "types": "./types/index.d.ts", - "default": "./dist/dev.js" + "default": "./dist/solid.dev.js" }, "require": { "types": "./types-cjs/index.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/solid.dev.cjs" } }, "import": { @@ -58,6 +68,16 @@ } }, "deno": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -68,6 +88,16 @@ } }, "node": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -80,11 +110,11 @@ "development": { "import": { "types": "./types/index.d.ts", - "default": "./dist/dev.js" + "default": "./dist/solid.dev.js" }, "require": { "types": "./types-cjs/index.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/solid.dev.cjs" } }, "import": { diff --git a/packages/solid/rollup.config.js b/packages/solid/rollup.config.js index 296519575..6f9c9d240 100644 --- a/packages/solid/rollup.config.js +++ b/packages/solid/rollup.config.js @@ -44,6 +44,10 @@ export default [ plugins: [replaceDev(false)].concat(plugins) }, { + // Prod server build. `src/server/` has no `"_SOLID_DEV_"` gates today, but + // the replace must run anyway: without it babel constant-folds the truthy + // string literal and the first gate anyone adds takes the dev branch in + // production (the #2982 failure @solidjs/web's server entry shipped with). input: "src/server/index.ts", output: [ { @@ -56,17 +60,37 @@ export default [ } ], external: ["@solidjs/signals", "stream"], - plugins + plugins: [replaceDev(false)].concat(plugins) + }, + { + // Dev server build, selected by the `development` condition nested under + // `node`/`worker`/`deno` in package.json exports (nested on purpose: at the + // top level `node` precedes `development` and would win). Until this + // existed, SSR had no dev build at all — server-side dev diagnostics had + // nowhere to run. Mirrors `@solidjs/web/server-functions`'s server.dev. + input: "src/server/index.ts", + output: [ + { + file: "dist/server.dev.cjs", + format: "cjs" + }, + { + file: "dist/server.dev.js", + format: "es" + } + ], + external: ["@solidjs/signals", "stream"], + plugins: [replaceDev(true)].concat(plugins) }, { input: "src/index.ts", output: [ { - file: "dist/dev.cjs", + file: "dist/solid.dev.cjs", format: "cjs" }, { - file: "dist/dev.js", + file: "dist/solid.dev.js", format: "es" } ], diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 0bec743d2..c103064cc 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -1,3 +1,5 @@ +import { DEV as _DEV, type Dev } from "@solidjs/signals"; + // From mock signals (same exports that index.ts pulls from @solidjs/signals) export { $PROXY, @@ -126,5 +128,11 @@ export function materializeContainerTrace(marker: unknown): unknown { return marker; } -// Dev — no dev mode on server -export const DEV = undefined; +// Dev — same shape as the client entry. `"_SOLID_DEV_"` is replaced per build +// (dist/server.dev.* → true, dist/server.* → false), so the dev artifact +// exposes @solidjs/signals' DEV object — its `diagnostics` channel is the bus +// server-side dev findings report through — and prod exports `undefined`. +// The server reimplements reactivity, so `DEV.attribution`/graph helpers +// have nothing to introspect here; the channel is what's shared. +const IS_DEV = "_SOLID_DEV_" as string | boolean; +export const DEV: Dev | undefined = IS_DEV ? _DEV : undefined; diff --git a/packages/solid/test/server/dist-server-artifact.spec.ts b/packages/solid/test/server/dist-server-artifact.spec.ts new file mode 100644 index 000000000..345d539d9 --- /dev/null +++ b/packages/solid/test/server/dist-server-artifact.spec.ts @@ -0,0 +1,39 @@ +/** @vitest-environment node */ +/** + * Direct coverage of the built server artifacts — dist/server.js (prod) and + * dist/server.dev.js (the `development` export condition, nested under + * node/worker/deno). The rest of the suite imports source, where the + * `"_SOLID_DEV_"` literal is truthy, so it cannot see whether the replace + * ran per artifact. `DEV` is the observable: the server entry gates it the + * same way the client does, so the dev artifact must expose @solidjs/signals' + * DEV object (the diagnostics channel server-side findings report through) + * and the prod artifact must export `undefined`. A string scan can't pin + * this — babel's constant folding erases the marker either way. + * Requires a prior `pnpm build`. + */ +import { describe, expect, test } from "vitest"; +// Relative imports on purpose: they bypass the `solid-js` → source alias in +// vite.config.mjs so the built artifacts themselves are under test. +// @ts-ignore — dist files have no adjacent type declarations. +import * as prod from "../../dist/server.js"; +// @ts-ignore +import * as dev from "../../dist/server.dev.js"; +import { DEV as signalsDEV } from "@solidjs/signals"; + +describe("solid-js server artifacts", () => { + test("dist/server.js (prod) exports DEV as undefined", () => { + expect(prod.DEV).toBeUndefined(); + }); + + test("dist/server.dev.js exports @solidjs/signals' DEV object", () => { + expect(dev.DEV).toBeDefined(); + // Not a copy or a stub: the very object signals exports, so a subscriber + // on `DEV.diagnostics` sees server findings without a second channel. + expect(dev.DEV).toBe(signalsDEV); + expect(typeof dev.DEV.diagnostics.subscribe).toBe("function"); + }); + + test("the two artifacts export the same surface", () => { + expect(Object.keys(dev).sort()).toEqual(Object.keys(prod).sort()); + }); +}); diff --git a/packages/solid/test/server/export-parity.spec.ts b/packages/solid/test/server/export-parity.spec.ts index f783ccbe3..d2fb46230 100644 --- a/packages/solid/test/server/export-parity.spec.ts +++ b/packages/solid/test/server/export-parity.spec.ts @@ -15,10 +15,15 @@ describe("Export parity: server mirrors client", () => { expect(missingFromServer).toEqual([]); }); - test("all function exports are functions (or undefined for DEV)", () => { + test("all function exports are functions (DEV is the same signals object as the client's)", () => { for (const [key, value] of Object.entries(server)) { if (key === "DEV") { - expect(value).toBeUndefined(); + // Both entries gate on `"_SOLID_DEV_"` (truthy here — source, not + // dist) and re-export @solidjs/signals' DEV, so the server's dev + // diagnostics channel IS the client's. dist/server.js exports + // `undefined`; the dist artifact specs pin that. + expect(value).toBeDefined(); + expect(value).toBe(client.DEV); continue; } if (typeof (client as any)[key] === "function") { diff --git a/packages/universal/package.json b/packages/universal/package.json index f7ccdfcc0..57f3549ed 100644 --- a/packages/universal/package.json +++ b/packages/universal/package.json @@ -29,11 +29,11 @@ "development": { "import": { "types": "./types/index.d.ts", - "default": "./dist/dev.js" + "default": "./dist/universal.dev.js" }, "require": { "types": "./types-cjs/index.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/universal.dev.cjs" } }, "import": { diff --git a/packages/universal/rollup.config.js b/packages/universal/rollup.config.js index 1dee517ef..0f144f2c3 100644 --- a/packages/universal/rollup.config.js +++ b/packages/universal/rollup.config.js @@ -47,11 +47,11 @@ export default [ input: "src/index.ts", output: [ { - file: "dist/dev.cjs", + file: "dist/universal.dev.cjs", format: "cjs" }, { - file: "dist/dev.js", + file: "dist/universal.dev.js", format: "es" } ], diff --git a/packages/web/frames/package.json b/packages/web/frames/package.json index 251b94818..ce6106523 100644 --- a/packages/web/frames/package.json +++ b/packages/web/frames/package.json @@ -17,6 +17,16 @@ "default": "./dist/client.cjs" } }, + "development": { + "import": { + "types": "../types/frames/server.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "../types-cjs/frames/server.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "../types/frames/server.d.ts", "default": "./dist/server.js" diff --git a/packages/web/package.json b/packages/web/package.json index 8adbe1299..380a4423c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -43,6 +43,16 @@ "exports": { ".": { "worker": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -56,11 +66,11 @@ "development": { "import": { "types": "./types/index.d.ts", - "default": "./dist/dev.js" + "default": "./dist/web.dev.js" }, "require": { "types": "./types-cjs/index.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/web.dev.cjs" } }, "import": { @@ -73,6 +83,16 @@ } }, "deno": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -83,6 +103,16 @@ } }, "node": { + "development": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/server.dev.cjs" + } + }, "import": { "types": "./types/index.d.ts", "default": "./dist/server.js" @@ -95,11 +125,11 @@ "development": { "import": { "types": "./types/index.d.ts", - "default": "./dist/dev.js" + "default": "./dist/web.dev.js" }, "require": { "types": "./types-cjs/index.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/web.dev.cjs" } }, "import": { @@ -124,11 +154,11 @@ "./jsx-dev-runtime": { "import": { "types": "./types/jsx.d.ts", - "default": "./dist/dev.js" + "default": "./dist/web.dev.js" }, "require": { "types": "./types-cjs/jsx.d.cts", - "default": "./dist/dev.cjs" + "default": "./dist/web.dev.cjs" } }, "./storage": { @@ -283,6 +313,16 @@ }, "./frames": { "worker": { + "development": { + "import": { + "types": "./types/frames/server.d.ts", + "default": "./frames/dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/frames/server.d.cts", + "default": "./frames/dist/server.dev.cjs" + } + }, "import": { "types": "./types/frames/server.d.ts", "default": "./frames/dist/server.js" @@ -313,6 +353,16 @@ } }, "deno": { + "development": { + "import": { + "types": "./types/frames/server.d.ts", + "default": "./frames/dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/frames/server.d.cts", + "default": "./frames/dist/server.dev.cjs" + } + }, "import": { "types": "./types/frames/server.d.ts", "default": "./frames/dist/server.js" @@ -323,6 +373,16 @@ } }, "node": { + "development": { + "import": { + "types": "./types/frames/server.d.ts", + "default": "./frames/dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/frames/server.d.cts", + "default": "./frames/dist/server.dev.cjs" + } + }, "import": { "types": "./types/frames/server.d.ts", "default": "./frames/dist/server.js" @@ -352,6 +412,16 @@ } }, "./frames/server": { + "development": { + "import": { + "types": "./types/frames/server.d.ts", + "default": "./frames/dist/server.dev.js" + }, + "require": { + "types": "./types-cjs/frames/server.d.cts", + "default": "./frames/dist/server.dev.cjs" + } + }, "import": { "types": "./types/frames/server.d.ts", "default": "./frames/dist/server.js" diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js index f275b24b6..6469129da 100644 --- a/packages/web/rollup.config.js +++ b/packages/web/rollup.config.js @@ -132,14 +132,13 @@ export default [ plugins: [replaceDev(false)].concat(plugins) }, { - // Prod build — the only node/worker/deno artifact for the main entry - // (SSR builds are production by convention; the server entry hard-codes - // isDev false). `_SOLID_DEV_` must strip to false here: without the - // replace, babel constant-folds the truthy "_SOLID_DEV_" string literal and - // the artifact permanently takes the DEV branch of every gate — most - // damaging the committed-stub header guard, which is spec'd to throw in - // dev but console.error + no-op in prod, so the shipped bundle turned a - // late header write into a crashed production request (#2982). Guarded + // Prod server build — the default node/worker/deno artifact for the main + // entry. `_SOLID_DEV_` must strip to false here: without the replace, babel + // constant-folds the truthy "_SOLID_DEV_" string literal and the artifact + // permanently takes the DEV branch of every gate — most damaging the + // committed-stub header guard, which is spec'd to throw in dev but + // console.error + no-op in prod, so the shipped bundle turned a late + // header write into a crashed production request (#2982). Guarded // behaviorally by test/server/dist-server-artifact.spec.tsx (a string // scan can't catch this: the folding erases the marker either way). input: "src/index.server.ts", @@ -156,15 +155,38 @@ export default [ external: ["solid-js", "stream", "seroval", "seroval-plugins/web"], plugins: [replaceDev(false)].concat(plugins) }, + { + // Dev server build (`development` condition nested under node/worker/deno + // in package.json — nested because top-level `node` would match first). + // Until this existed the 26 `_SOLID_DEV_` gates in src/server.ts (head and + // preload descriptor validation, useHead warnings, the late-header throw) + // were stripped from the only server artifact and never ran outside the + // test suite. Same shape as server-functions/dist/server.dev below. + // Guarded by test/server/dist-server-dev-artifact.spec.tsx: the dev + // artifact must THROW on a late header write where prod reports and drops. + input: "src/index.server.ts", + output: [ + { + file: "dist/server.dev.cjs", + format: "cjs" + }, + { + file: "dist/server.dev.js", + format: "es" + } + ], + external: ["solid-js", "stream", "seroval", "seroval-plugins/web"], + plugins: [replaceDev(true)].concat(plugins) + }, { input: "src/index.ts", output: [ { - file: "dist/dev.cjs", + file: "dist/web.dev.cjs", format: "cjs" }, { - file: "dist/dev.js", + file: "dist/web.dev.js", format: "es" } ], @@ -390,5 +412,25 @@ export default [ ], external: ["solid-js", "stream", "seroval", "seroval-plugins/web"], plugins: [replaceDev(false)].concat(plugins) + }, + { + // Dev server build for frames (`development` nested under node/worker/deno + // in the `./frames` export and the `./frames/server` subpath). Keeps the + // bundled SSR pipeline's `_SOLID_DEV_` gates live in dev SSR, matching the + // main dist/server.dev entry above. + input: "frames/src/server.ts", + output: [ + { + file: "frames/dist/server.dev.cjs", + format: "cjs", + exports: "auto" + }, + { + file: "frames/dist/server.dev.js", + format: "es" + } + ], + external: ["solid-js", "stream", "seroval", "seroval-plugins/web"], + plugins: [replaceDev(true)].concat(plugins) } ]; diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index 3e7a5bc07..b0017bed8 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -49,10 +49,13 @@ export const isServer: boolean = true; /** * Build-time constant indicating whether code is running in a dev build. - * The server entry hard-codes `false` (SSR builds are production by - * convention); the client entry's value is set by `_SOLID_DEV_` substitution. + * Set by `_SOLID_DEV_` substitution per artifact, exactly like the client + * entry: `dist/server.dev.*` (the `development` export condition) → `true`, + * `dist/server.*` → `false`. The server runtime's own `_SOLID_DEV_` gates are + * replaced by the same pass, so this flag and the bundle's internal behavior + * cannot disagree. */ -export const isDev: boolean = false; +export const isDev: boolean = "_SOLID_DEV_" as unknown as boolean; export type IntrinsicElement = Extract; export type ValidComponent = IntrinsicElement | Component | (string & {}); diff --git a/packages/web/test/server/dist-server-artifact.spec.tsx b/packages/web/test/server/dist-server-artifact.spec.tsx index f9cf77fe3..9ba253fa6 100644 --- a/packages/web/test/server/dist-server-artifact.spec.tsx +++ b/packages/web/test/server/dist-server-artifact.spec.tsx @@ -18,9 +18,13 @@ import { describe, expect, test, vi } from "vitest"; // in vite.config.server.mjs so the built artifact itself is under test. // @ts-ignore — the dist file has no adjacent type declarations; the runtime // behavior is what's being asserted. -import { commitEventResponse, createRequestEvent } from "../../dist/server.js"; +import { commitEventResponse, createRequestEvent, isDev } from "../../dist/server.js"; describe("dist/server.js production artifact", () => { + test("exports isDev === false", () => { + expect(isDev).toBe(false); + }); + test("post-commit header writes report through console.error and no-op (#2982)", () => { const event = createRequestEvent(new Request("http://localhost/")); commitEventResponse(new Response("body"), event); diff --git a/packages/web/test/server/dist-server-dev-artifact.spec.tsx b/packages/web/test/server/dist-server-dev-artifact.spec.tsx new file mode 100644 index 000000000..54bd3136e --- /dev/null +++ b/packages/web/test/server/dist-server-dev-artifact.spec.tsx @@ -0,0 +1,59 @@ +/** + * @jsxImportSource @solidjs/web + * + * Direct coverage of the built DEV server artifact (dist/server.dev.js) — the + * twin of dist-server-artifact.spec.tsx. Until this artifact existed, SSR had + * no dev build: the only server bundle was `replaceDev(false)`, so the 26 + * `_SOLID_DEV_` gates in src/server.ts (head/preload validation, useHead + * warnings, the late-header throw) were stripped from every deployment and + * only ever ran in this suite, which imports source. The dev artifact is + * selected by the `development` export condition nested under + * node/worker/deno; this spec pins that the replace actually ran as `true` + * (a string scan can't — babel's folding erases the marker either way), by + * asserting the one gate whose dev and prod behaviors differ observably. + * Requires a prior `pnpm build`. + */ +import { describe, expect, test, vi } from "vitest"; +// Relative import on purpose: bypasses the `@solidjs/web` → source alias in +// vite.config.server.mjs so the built artifact itself is under test. +// @ts-ignore — the dist file has no adjacent type declarations. +import { commitEventResponse, createRequestEvent, isDev } from "../../dist/server.dev.js"; + +describe("dist/server.dev.js development artifact", () => { + test("exports isDev === true, agreeing with its internal gates", () => { + // The public flag and the bundle's `_SOLID_DEV_` gates come from the same + // replace pass; this pins that the server entry no longer hard-codes it. + expect(isDev).toBe(true); + }); + + test("post-commit header writes THROW (dev contract of #2982)", () => { + const event = createRequestEvent(new Request("http://localhost/")); + commitEventResponse(new Response("body"), event); + expect(event.response.committed).toBe(true); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + // The production artifact reports and drops; the dev artifact must + // surface the bug loudly. Both are pinned so a build-mode swap in either + // direction fails a test. + expect(() => event.response.headers.set("x-late", "1")).toThrow( + /ran after the response head was sent/ + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(event.response.headers.get("x-late")).toBeNull(); + } finally { + errorSpy.mockRestore(); + } + }); + + test("append and delete throw on the same committed-stub guard", () => { + const event = createRequestEvent(new Request("http://localhost/")); + event.response.headers.set("x-early", "kept"); + commitEventResponse(new Response("body"), event); + + expect(() => event.response.headers.append("x-late", "1")).toThrow(); + expect(() => event.response.headers.delete("x-early")).toThrow(); + expect(event.response.headers.get("x-early")).toBe("kept"); + expect(event.response.headers.get("x-late")).toBeNull(); + }); +}); diff --git a/packages/web/test/server/exports-server-conditions.spec.tsx b/packages/web/test/server/exports-server-conditions.spec.tsx new file mode 100644 index 000000000..fa9e182ac --- /dev/null +++ b/packages/web/test/server/exports-server-conditions.spec.tsx @@ -0,0 +1,139 @@ +/** + * @jsxImportSource @solidjs/web + * + * Pins how the server entry points of `solid-js` and `@solidjs/web` resolve + * under Node's real exports algorithm, with and without the `development` + * condition. Two invariants: + * + * 1. Pairing — every server entry flips to its `.dev` artifact together. The + * web server bundle externalizes `solid-js`, so the host resolves each + * package independently; a dev `@solidjs/web` over a prod `solid-js` (or + * the reverse) is a mixed build nobody tested. + * 2. Ordering — `development` must be NESTED under `node`/`worker`/`deno`. + * Exports conditions match in key order, and every server condition + * precedes the top-level `development` key, so a top-level entry would + * silently never match on a server. This was the shape that left SSR with + * no dev build at all until dist/server.dev.* existed. + * + * Resolution is done by spawning Node with `--conditions` rather than by + * reimplementing the exports algorithm in-test: a host runtime is what does + * this for real, and Node's resolver is the reference. Runs from + * packages/web, where `node_modules/solid-js` and the self-link + * `node_modules/@solidjs/web` (the `link` build step) both exist. + */ +import { execFileSync } from "node:child_process"; +import { describe, expect, test } from "vitest"; + +const SPECIFIERS = [ + "solid-js", + "@solidjs/web", + "@solidjs/web/frames", + "@solidjs/web/frames/server", + "@solidjs/web/server-functions" +] as const; + +function resolveAll(conditions: string[]): Record { + const script = + `const out = {}; for (const s of ${JSON.stringify(SPECIFIERS)}) ` + + `out[s] = import.meta.resolve(s).replace(/^.*\\/packages\\//, ""); ` + + `process.stdout.write(JSON.stringify(out));`; + const stdout = execFileSync( + process.execPath, + [...conditions.map(c => `--conditions=${c}`), "--input-type=module", "-e", script], + { cwd: process.cwd(), encoding: "utf8" } + ); + return JSON.parse(stdout); +} + +describe("export conditions: dev/prod artifact pairing", () => { + test("default node resolution reaches the production server artifacts", () => { + expect(resolveAll([])).toEqual({ + "solid-js": "solid/dist/server.js", + "@solidjs/web": "web/dist/server.js", + "@solidjs/web/frames": "web/frames/dist/server.js", + "@solidjs/web/frames/server": "web/frames/dist/server.js", + "@solidjs/web/server-functions": "web/server-functions/dist/server.js" + }); + }); + + test("`development` flips every server entry to its dev artifact together", () => { + expect(resolveAll(["development"])).toEqual({ + "solid-js": "solid/dist/server.dev.js", + "@solidjs/web": "web/dist/server.dev.js", + "@solidjs/web/frames": "web/frames/dist/server.dev.js", + "@solidjs/web/frames/server": "web/frames/dist/server.dev.js", + "@solidjs/web/server-functions": "web/server-functions/dist/server.dev.js" + }); + }); + + test("`browser` selects the client artifacts, named `.dev.js` like the server ones", () => { + // Node still adds its own `node` condition here, so this also pins that + // `browser` precedes `node`/`worker`/`deno` in every exports map — a + // bundler targeting the browser must never be handed a server bundle. + expect(resolveAll(["browser"])).toEqual({ + "solid-js": "solid/dist/solid.js", + "@solidjs/web": "web/dist/web.js", + "@solidjs/web/frames": "web/frames/dist/client.js", + "@solidjs/web/frames/server": "web/frames/dist/server.js", + "@solidjs/web/server-functions": "web/server-functions/dist/client.js" + }); + expect(resolveAll(["browser", "development"])).toEqual({ + "solid-js": "solid/dist/solid.dev.js", + "@solidjs/web": "web/dist/web.dev.js", + "@solidjs/web/frames": "web/frames/dist/client.dev.js", + // `./frames/server` is server-only by name; it has no client half. + "@solidjs/web/frames/server": "web/frames/dist/server.dev.js", + // The server-functions client has no `_SOLID_DEV_` gates, hence no dev + // artifact — the one intentional gap in the grid. + "@solidjs/web/server-functions": "web/server-functions/dist/client.js" + }); + }); + + test("CJS `require` pairs solid-js's server.dev.cjs with signals' node.dev.cjs", () => { + // The web server bundle externalizes solid-js, and solid-js's server + // bundle externalizes @solidjs/signals, so a CJS host resolves each in + // turn. The dev server artifact is only honest if the signals it requires + // is dev too — its `DEV` export is signals' object — so the `require` + // branch of signals needs its own `development` entry, and this pins + // both hops flipping together. + const script = + `const { createRequire } = require("node:module"); ` + + `const r = createRequire(process.cwd() + "/"); ` + + `const solid = r.resolve("solid-js"); ` + + `const signals = createRequire(solid).resolve("@solidjs/signals"); ` + + `const web = r.resolve("@solidjs/web"); ` + + `process.stdout.write(JSON.stringify([web, solid, signals].map(p => p.replace(/^.*\\/packages\\//, ""))));`; + const run = (conditions: string[]) => + JSON.parse( + execFileSync( + process.execPath, + [...conditions.map(c => `--conditions=${c}`), "-e", script], + { cwd: process.cwd(), encoding: "utf8" } + ) + ); + expect(run([])).toEqual([ + "web/dist/server.cjs", + "solid/dist/server.cjs", + "signals/dist/node.cjs" + ]); + expect(run(["development"])).toEqual([ + "web/dist/server.dev.cjs", + "solid/dist/server.dev.cjs", + "signals/dist/node.dev.cjs" + ]); + }); + + test("worker and deno conditions carry the same dev/prod pairing as node", () => { + // Node always adds its own `node` condition; passing `worker`/`deno` on top + // exercises those keys' nesting (they precede `node` in every exports map + // here, so they win and must carry their own nested `development`). + for (const platform of ["worker", "deno"]) { + const prod = resolveAll([platform]); + const dev = resolveAll([platform, "development"]); + for (const s of SPECIFIERS) { + expect(prod[s], `${s} under ${platform}`).toMatch(/\/server\.js$/); + expect(dev[s], `${s} under ${platform}+development`).toMatch(/\/server\.dev\.js$/); + } + } + }); +}); diff --git a/turbo.json b/turbo.json index ed276c178..9831dcf6d 100644 --- a/turbo.json +++ b/turbo.json @@ -80,7 +80,7 @@ "outputs": [] }, "solid-js#test": { - "dependsOn": ["@solidjs/signals#build"], + "dependsOn": ["@solidjs/signals#build", "solid-js#build"], "outputs": [] }, "@solidjs/web#test": {