Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/initial-route-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
---

The route the document arrived on, declared by the router with the call it already uses. `NavigationRef.initial` on `OBSERVE.attribution.withOrigin`: a router wraps the work that establishes its initial match (building its context) instead of a location write, on both sides. On the client the attribution engine opens the frame at the time origin (`at` defaults to `0`, the document's own navigation start; a router mounted late passes its own), takes no `from`, and settles it `committed` with `writes: 0` when the frame closes, so the first `"navigation"` record (`NavigationEvent.initial: true`) names the route the page loaded as — the pageload's route pattern, which every navigation but the first already had. It is a declaration, not a timing: kept out of `feedback().navigations`. On the server, where there is no engine, the server entry's `withOrigin` files the ref on the render, and the request's `"render"` record carries it as `RenderEvent.route` (`{ name, to, params }`, read from the ref when the render settles) — the name a consumer gives the request (`http.route`) where the URL would scatter one page across as many names as it has parameters. New type `RenderRoute` from `@solidjs/web`.

`NavigationRef.interaction`: a router that awaits between the request and the write (guards or loaders resolved in its core before it publishes the location) captures `OBSERVE.attribution.currentOrigin()` in the request and hands it back on the ref, and the write it publishes later joins the click as if it had been synchronous. Declared beats ambient: the key's presence is the declaration, and an interaction on the stack at write time is used only when the key is absent.

`formatOrigin` renders the initial declaration `initial navigation to /users/:id (/users/42)`. Prod artifacts byte-identical; the observe tier's client artifacts byte-identical.
39 changes: 37 additions & 2 deletions documentation/plans/responsiveness-findings-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ Two items change a contract; settle them before their code.
the continuation are not attributed — no frame). The finding is
`UNTRACKED_ASYNC_HANDLER`, on the hold thresholds; an `action()` step or a
write before the `await` clears it. `@sentry/solid-2`'s `after_settle`
path is now dead and should go. Original question: (↔ Tracks: the Interactions track's settle span ends at
path narrows to the one shape this does not observe — a handler that
dispatches a call and returns without the promise (`save().then(set)`)
— and stays for it (see Consumers → Sentry). Original question: (↔ Tracks: the Interactions track's settle span ends at
`settledMs`; D1 lengthens it. Decide before Stage 1 of that plan ships a
shape.) Today `withInteraction` closes the frame when the synchronous
handler returns; `onClick={async () => set(await save())}` settles as
Expand Down Expand Up @@ -395,7 +397,40 @@ appear, and they shape which fields the records need.
waiting on to replace `after_settle` and to join its INP span to a cause;
item 4's findings become issues without adapter changes. The reviewer
brief in `getsentry/sentry-javascript` (`docs/solid-2-observe.md`) points
here from its open questions.
here from its open questions. Item 1 landed and the adapter kept
`after_settle`, narrowed: #3604 observes a _returned_ thenable, so
`onClick={() => { save().then(set) }}` still settles `idle` at once and
its call lands afterwards carrying the interaction's frame; the marker now
names exactly that shape.
- **Parity table stake, missed by the audits above: route-parameterised
transaction names — LANDED (runtime).** Every peer framework SDK renames
the `pageload`/`navigation` idle spans to the matched route pattern
(`/users/:id`, source `route`); Sentry's Performance product keys on that
name, and the 1.x `@sentry/solid` was two files of exactly this
(`solidRouterBrowserTracingIntegration`, `withSentryRouterRouting`). The
audits asked "what can Solid observe that React cannot" and never
"what does a Sentry framework SDK have to do", and the e2e app had one
route and no router. The data was flowing for every navigation but the
first — `NavigationEvent.name` is the pattern — and missing on the server
entirely. Closed on the runtime side by one declaration a router makes
with the call it already uses: `withOrigin({ kind: "navigation",
initial: true, … })` around its initial match, on both sides. Client: the
first `"navigation"` record (`initial: true`, `at` the time origin,
`writes: 0`, settled on the no-write rule, kept out of the feedback fold).
Server: `RenderEvent.route` on the request's `"render"` record, filed by
the server entry's `withOrigin` through the render context. Beside it,
`NavigationRef.interaction` lets a router that awaits before writing
(TanStack's load transaction) hand back the origin it captured with
`currentOrigin()`, so its write still joins the click. Router side:
`createRouterContext` declares the initial match. Adapter side (pending
the release): rename the pageload root from the initial record; drive the
navigation idle span from records (`instrumentNavigation: false`, start
at `nav.at` with the route name) instead of painting a parallel span;
`http.route` on the `http.server` span from `render.route`.
**Process fix, for the next audit:** before calling an integration
comparable, enumerate the peer SDKs' exports and default integrations and
tick them off; give the e2e app at least two routes, one parameterised,
so the transaction list is something a test asserts on.

## Not a runtime job

Expand Down
20 changes: 16 additions & 4 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,12 +807,13 @@ The **`"render"` record** (from `@solidjs/web`, server) is one server render —
```js
const off = OBSERVE.records.subscribe("render", (event, live) => {
// event: { mode: "string" | "stream", at, shellMs?, durationMs, boundaries,
// outcome: "complete" | "abandoned" | "error" }
// outcome: "complete" | "abandoned" | "error",
// route?: { name?, to?, params? } }
// live: { event?: RequestEvent, trace: TraceContext }
});
```

One record per render, delivered when it **ends**: the document returned, the stream's last fragment written, or the render torn down. `at` is `performance.now()` at the render's start; `shellMs` runs start → the shell complete — for a stream, the head and shell handed to the sink (the head is frozen from there; a fragment can no longer add to it), for a string, the document assembled (the whole render) — and is absent when the render ended before its shell; `durationMs` runs start → the end. `boundaries` counts the `<Loading>` boundaries the shell **waited on** — each also a `"boundary"` record with `streamed: false` — and not the ones that streamed after it; it is counted from the boundary records the render filed, so in an observe build it needs a `"boundary"` listener too (`0` with a `"render"` listener alone). `outcome` is `"complete"` for a render that ran to its end, `"abandoned"` when the consumer left mid-stream (the `SSR_STREAM_ABANDONED` finding is that request's account), `"error"` when the render failed (a string render threw; a stream's uncontained failure wound it down). `live.event` is the request the render served, absent for a render outside a request scope; `live.trace` is the render's trace context (what `getTraceContext()` answers during it). `solid-shell` on the response's `Server-Timing` is this record's `shellMs`, read off the same object at head commit (below, [Chrome Performance panel](#chrome-performance-panel-solidjswebperformance-tracks)). The cost is paid only with a listener installed (or in dev): a render nobody observes reads no clock.
One record per render, delivered when it **ends**: the document returned, the stream's last fragment written, or the render torn down. `at` is `performance.now()` at the render's start; `shellMs` runs start → the shell complete — for a stream, the head and shell handed to the sink (the head is frozen from there; a fragment can no longer add to it), for a string, the document assembled (the whole render) — and is absent when the render ended before its shell; `durationMs` runs start → the end. `boundaries` counts the `<Loading>` boundaries the shell **waited on** — each also a `"boundary"` record with `streamed: false` — and not the ones that streamed after it; it is counted from the boundary records the render filed, so in an observe build it needs a `"boundary"` listener too (`0` with a `"render"` listener alone). `outcome` is `"complete"` for a render that ran to its end, `"abandoned"` when the consumer left mid-stream (the `SSR_STREAM_ABANDONED` finding is that request's account), `"error"` when the render failed (a string render threw; a stream's uncontained failure wound it down). `live.event` is the request the render served, absent for a render outside a request scope; `live.trace` is the render's trace context (what `getTraceContext()` answers during it). `solid-shell` on the response's `Server-Timing` is this record's `shellMs`, read off the same object at head commit (below, [Chrome Performance panel](#chrome-performance-panel-solidjswebperformance-tracks)). The cost is paid only with a listener installed (or in dev): a render nobody observes reads no clock. `route` is the route the render resolved to, as the router declared it while building its context under this render (`OBSERVE.attribution.withOrigin` with an `initial` ref — the same call that opens the client's first `"navigation"` record): `name` the matched pattern (`/users/:id`), `to` the concrete path, `params` what it bound; read from the router's ref when the render settles, so a match refined during the render is what lands, and absent when no router declared one. It is the name a consumer gives the request (`http.route`), where the URL would scatter one page across as many names as it has parameters.

The **`"call"` record** (from `@solidjs/web`, client) is one server-function call made from the browser — the invocation's twin, seen from the caller's end:

Expand Down Expand Up @@ -1048,6 +1049,16 @@ OBSERVE
() => setLocation(to)
)
: setLocation(to);
// And the route the document arrived on, around the work that establishes
// its initial match (there is no location write on a fresh document). On the
// client this is the first "navigation" record; on the server it names the
// request's "render" record (`RenderEvent.route`). Same call on both sides.
OBSERVE
? OBSERVE.attribution.withOrigin(
{ kind: "navigation", initial: true, name: "/users/:id", to, params: match.params },
() => buildContext()
)
: buildContext();
// A runtime recording a fact of its own asks what a write here would be
// stamped with — the engine's own interaction/navigation object, or
// undefined — and puts it on its record (the web runtime's "call" record
Expand Down Expand Up @@ -1103,7 +1114,7 @@ Effect- and action-origin writes resolve through the record of the run they belo

A navigation in Solid 2 is a plain write to the location — reads pull the route's async and the runtime holds the write until the data is ready — so the engine already sees everything a navigation costs. What it cannot see is that the writes _were_ a navigation, and to which route. `OBSERVE.attribution.withOrigin({ kind: "navigation", … }, fn)` is where a router says so, around its write; every router (or hand-rolled one) adds that one call, and nothing else anywhere is router-specific. From it the engine keeps one `NavigationEvent` per frame:

- `name`, `to`, `from`, `params`, `at` — what the router described; `interaction` — the link click (or other event) it ran under, when known, including through an action step (`navigate()` after a `yield`).
- `name`, `to`, `from`, `params`, `at` — what the router described; `interaction` — the link click (or other event) it ran under, when known, including through an action step (`navigate()` after a `yield`) or declared on the ref by a router that awaited before writing; `initial` — the route the document arrived on, declared at the router's initial match (see below).
- `writes` — root writes the frame performed, redirect hops included.
- `settledMs` and `outcome`, once its writes are through: `committed` (a plain drain took them — settle is the end of that drain, the instant the screen had them), `held` (they waited in a transition — settle is its commit, and `hold` carries the `HoldEvent` when hold tracking recorded one), or `superseded` (a later write to the same node replaced them before they landed — the user navigated again).
- `redirects` — present when a guard or loader sent the navigation elsewhere before it landed: the destinations abandoned along the way, in order, each with the time the redirect away from it was declared. `name`/`to`/`params` are then the final destination.
Expand All @@ -1113,7 +1124,8 @@ Two things about the frame are read late, on purpose:

- **The ref is re-read at settle.** The engine keeps the object passed to `withOrigin` and copies `name`, `to` and `params` from it again when the navigation settles (and when a hold on it is judged). A router whose match is not final at write time — a lazy route subtree that resolves inside the hold — describes coarsely (`/admin/*`), then assigns the exact pattern and params onto the same object once it knows them; the settled record, the hold's verdict and the feedback row all read the refined name. `from` and `at` are read once, when the frame opens.
- **A redirect is a hop, not a new navigation.** A router declares a redirect with `redirect: n` (`n >= 1`, the hop depth it already tracks). The engine re-enters the pending navigation's frame instead of opening one: the hop's write replaces the pending one with the same origin, so nothing is superseded; the record keeps the user's request time and interaction, so `settledMs` runs from the click, not the hop; the abandoned destination moves to `redirects`. With no pending navigation to fold onto, a `redirect` frame opens a navigation of its own.
- **One rule for every router: wrap the write whose landing is the destination showing.** Solid Router's navigation is a location write whose downstream the runtime holds until route data lands, so "writes through" is "navigation done" and the frame goes around that write. A router that awaits part of its pipeline outside the graph (TanStack Router's load transaction: the location moves at once, matches are published only when the loaders resolve) wraps the _publish_ instead, and passes `at: startedAt` — the user's request time, on the `performance.now()` clock — so the span starts at the click rather than at the write. The engine has no router-specific seam beyond that: a navigation the router abandons before it publishes is the router's to report, and the wait it owns is inside `settledMs` only through `at`. (Making the loader wait itself part of the transition, so the location write is the one to wrap, is router work — planned for the Solid 2 TanStack adapter, not yet done.) `params` values may be `undefined` (an optional segment left unbound).
- **One rule for every router: wrap the write whose landing is the destination showing.** Solid Router's navigation is a location write whose downstream the runtime holds until route data lands, so "writes through" is "navigation done" and the frame goes around that write. A router that awaits part of its pipeline outside the graph (TanStack Router's load transaction: the location moves at once, matches are published only when the loaders resolve) wraps the _publish_ instead, and passes `at: startedAt` — the user's request time, on the `performance.now()` clock — so the span starts at the click rather than at the write. The engine has no router-specific seam beyond that: a navigation the router abandons before it publishes is the router's to report, and the wait it owns is inside `settledMs` only through `at`. (Making the loader wait itself part of the transition, so the location write is the one to wrap, is router work — planned for the Solid 2 TanStack adapter, not yet done.) Such a router also loses the click: the interaction frame is gone by the time it publishes. It captures `OBSERVE.attribution.currentOrigin()` in the request and hands it back as `interaction` on the ref, and the record — and every hold and re-run the write causes — joins the click as if the write had been synchronous. Declared beats ambient: the key's presence is the declaration (`interaction: currentOrigin()` with nothing in effect at the request declares "for no interaction"), and what is on the stack at write time is used only when the key is absent. `params` values may be `undefined` (an optional segment left unbound).
- **The first navigation has no write: declare it.** The route the document arrived on is the one a consumer naming page loads needs most, and nothing writes a location for it. A router declares it with `initial: true` around the work that establishes its initial match — building its context — on both sides, with the same call. On the client the engine opens the frame at the time origin (`at` defaults to `0`, the document's own navigation start, so the record joins Navigation Timing; a router mounted long after the load passes its own `at`), takes no `from`, and settles it `committed` with `writes: 0` when the frame closes — the existing no-write rule — so the record is delivered as the router finishes describing the route, `initial: true` on it. It is a declaration, not a timing: `settledMs` is document start → router built, and the record is kept out of `feedback().navigations`, whose `settledMs` means "what the person waited"; the holds the mount waits in are their own records. On the server there is no engine, and the same call files the ref on the render — `RenderEvent.route` (`name`, `to`, `params`, read from the ref when the render settles) names the request the way `http.route` wants it, where the URL would scatter one page across as many names as it has parameters. `formatOrigin` renders it `initial navigation to /users/:id (/users/42)`.

A navigation that changed nothing (no write survived the equality gate) settles at once with `writes: 0`. `formatOrigin` renders the kind as `navigation to /users/:id (/users/42)` — after a redirect, `navigation to /login (redirected from /users/42)` — and cause chains under a click read `— navigation to /users/:id (under click on a.nav "Alice")`. `feedback().navigations` folds settled events per route (the final one, after redirects).

Expand Down
Loading
Loading