From 95429ec02759744167af31863d61a13722e6b1d0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 03:02:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(observe):=20the=20route=20the=20document?= =?UTF-8?q?=20arrived=20on=20=E2=80=94=20NavigationRef.initial,=20Navigati?= =?UTF-8?q?onRef.interaction,=20RenderEvent.route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A router declares its initial match with the call it already uses for navigations, on both sides: `withOrigin({ kind: "navigation", initial: true, … })` around the work that builds its context. Client: the engine opens the frame at the time origin, takes no `from`, settles it on the no-write rule — the first "navigation" record, `initial: true`, kept out of the feedback fold. Server: the server entry's `withOrigin` files the ref through the render context (`_declareRoute`) and the request's "render" record carries `RenderEvent.route` — the `http.route` a consumer names the request by. `NavigationRef.interaction`: a router that awaits before writing hands back the origin it captured with `currentOrigin()`; declared beats ambient, the key's presence is the declaration. Prod artifacts byte-identical; observe client artifacts byte-identical (tier cap +25 B of mangler layout); engine +122 B. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- .changeset/initial-route-declaration.md | 11 ++ .../plans/responsiveness-findings-plan.md | 39 +++- documentation/solid-2.0/08-dev-diagnostics.md | 20 +- .../signals/src/core/attribution-hooks.ts | 28 +++ packages/signals/src/core/attribution.ts | 45 ++++- .../tests/attribution-navigation.test.ts | 160 +++++++++++++++- .../skills/reactivity-diagnostics/SKILL.md | 7 +- packages/solid/src/server/index.ts | 9 +- packages/solid/src/server/shared.ts | 44 +++++ packages/web/src/client.ts | 3 +- packages/web/src/index.server.ts | 3 +- packages/web/src/observe.ts | 23 +++ packages/web/src/server-observe.ts | 23 ++- packages/web/src/server.ts | 4 + packages/web/test/observe.type-tests.ts | 5 + .../test/server/server-render-route.spec.tsx | 175 ++++++++++++++++++ scripts/size/scenarios.js | 20 +- 17 files changed, 599 insertions(+), 20 deletions(-) create mode 100644 .changeset/initial-route-declaration.md create mode 100644 packages/web/test/server/server-render-route.spec.tsx diff --git a/.changeset/initial-route-declaration.md b/.changeset/initial-route-declaration.md new file mode 100644 index 000000000..a99e61bfd --- /dev/null +++ b/.changeset/initial-route-declaration.md @@ -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. diff --git a/documentation/plans/responsiveness-findings-plan.md b/documentation/plans/responsiveness-findings-plan.md index ad5837001..93bfef519 100644 --- a/documentation/plans/responsiveness-findings-plan.md +++ b/documentation/plans/responsiveness-findings-plan.md @@ -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 @@ -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 diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index dfce0057e..22a22ca5c 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -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 `` 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 `` 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: @@ -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 @@ -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. @@ -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). diff --git a/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 2191709ed..c27119554 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -291,6 +291,34 @@ export interface NavigationRef { * a pending navigation to fold onto it opens a navigation of its own. */ redirect?: number; + /** + * The route the document arrived on — declared by the router around the + * work that establishes its initial match (building its context), not + * around a write: there is no location write on a fresh document, and + * without this a consumer has the route pattern for every navigation + * but the first. The record opens at `at`, which defaults to `0` (the + * `performance.now()` origin — the document's own navigation start, so + * the record joins Navigation Timing); a router mounted long after the + * document loaded passes its own start. It settles when the frame closes, + * `committed` with no writes: it declares the route, it does not time the + * mount — the holds the mount waits in are their own records. `from` is + * meaningless for it and ignored. + */ + initial?: boolean; + /** + * The interaction the navigation is for, when the router already knows it + * will not be on the stack at write time. 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 here, and the record — and every hold + * and re-run its write causes — joins the click as if the write had been + * synchronous. Any origin will do: the engine takes the interaction it + * carries. Declared beats ambient: the key's presence is the declaration + * — `interaction: currentOrigin()` with nothing in effect at the request + * (`undefined`) declares that the navigation was for no interaction, and an + * interaction on the stack at write time is used only when the key is absent. + */ + interaction?: ChangeOrigin; } export let attrHooks: AttributionHooks | null = null; diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index bd4b71e0c..af507580c 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -1031,9 +1031,16 @@ function originStart(ref: NavigationRef): void { return; } } - const frame: ChangeOrigin = { kind: ref.kind, at: ref.at ?? now() }; - if (ref.from !== undefined) frame.from = ref.from; - const under = enclosingInteraction(); + // The initial navigation is the document's: its request is the time origin + // unless the router says otherwise, and it left nowhere. + const initial = ref.initial === true; + const frame: ChangeOrigin = { kind: ref.kind, at: ref.at ?? (initial ? 0 : now()) }; + if (!initial && ref.from !== undefined) frame.from = ref.from; + // Declared beats ambient: a router that awaited before writing hands back + // the origin it captured in the request (`undefined` when the request ran + // under none — still a declaration); what is on the stack now is whatever + // happened to be running. + const under = "interaction" in ref ? interactionOf(ref.interaction) : enclosingInteraction(); if (under !== undefined) frame.interaction = under; originFrames.push(frame); openNavigation(frame, ref); @@ -1069,7 +1076,8 @@ export function formatOrigin(origin: ChangeOrigin): string { notes.push( `redirected from ${redirects.map(hop => hop.to ?? hop.name ?? "?").join(" → ")}` ); - return `navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`; + const initial = navStates.get(origin)?.event.initial === true; + return `${initial ? "initial " : ""}navigation to ${name}${notes.length > 0 ? ` (${notes.join(", ")})` : ""}`; } default: return "outside the reactive system"; @@ -3350,7 +3358,19 @@ function checkStackedHolds(t: Transition, hold: HoldEvent, subject: Signal) // outside the graph (loaders resolved in its core before it publishes) wraps // the publish instead, passing `at` from the user's request — the record then // covers the write that actually showed, and the router-side wait is the -// router's to report. +// router's to report. Such a router captures `currentOrigin()` in the request +// and hands it back as `ref.interaction`, so the write it publishes later +// still joins the click (declared beats ambient). +// +// The one navigation that has no write is the first: the route the document +// arrived on. A router declares it with `ref.initial` around the work that +// establishes its initial match (building its context); the frame opens at +// the time origin (or `ref.at`), takes no `from`, and settles `committed` +// with no writes when the frame closes — the existing no-write rule, so the +// record is delivered as the router finishes describing the route. It is a +// declaration, not a timing: it is kept out of the feedback tables, whose +// `settledMs` means "what the person waited", and consumers that name a page +// load by its route read it from the `navigation` channel like any other. /** A destination a navigation abandoned when a redirect sent it elsewhere. */ export interface NavigationHop { @@ -3369,6 +3389,15 @@ export interface NavigationEvent { params?: Readonly>; /** When the navigation was requested (`performance.now()` clock). */ at: number; + /** + * The route the document arrived on, declared by the router at its initial + * match (`NavigationRef.initial`): `at` is the document's navigation start + * (`0` unless the router passed its own), there is no `from`, `writes` is + * `0`, and it settles `committed` when the router's frame closes — the + * record names the route the page loaded as; its timing is Navigation + * Timing's, not the runtime's. + */ + initial?: true; /** The user interaction it ran under, when known — a link click. */ interaction?: ChangeOrigin; /** Root writes the frame performed, redirect hops included. */ @@ -3434,6 +3463,7 @@ function syncNavigation(state: NavState): void { function openNavigation(frame: ChangeOrigin, ref: NavigationRef): void { const event: NavigationEvent = { at: frame.at!, writes: 0, origin: frame }; + if (ref.initial === true) event.initial = true; if (frame.from !== undefined) event.from = frame.from; if (frame.interaction !== undefined) event.interaction = frame.interaction; const state: NavState = { @@ -3719,7 +3749,10 @@ function settleNavigation( event.settledMs = now() - event.at; event.outcome = outcome; if (hold !== undefined) event.hold = hold; - for (const f of folds) f.navigation?.(event); + // The initial declaration is not a navigation the person waited on: its + // `settledMs` is document start → router built, which would read as the + // route's responsiveness in the feedback tables. Consumers get the record. + if (event.initial !== true) for (const f of folds) f.navigation?.(event); records.emit("navigation", event, undefined); trackGraph(event); // The interaction that performed it may have been waiting only on this. diff --git a/packages/signals/tests/attribution-navigation.test.ts b/packages/signals/tests/attribution-navigation.test.ts index 072dc7952..288bcebab 100644 --- a/packages/signals/tests/attribution-navigation.test.ts +++ b/packages/signals/tests/attribution-navigation.test.ts @@ -26,7 +26,7 @@ import { OBSERVE } from "../src/index.js"; import type { NavigationRef } from "../src/index.js"; -import type { RerunEvent } from "../src/core/attribution.js"; +import type { NavigationEvent, RerunEvent } from "../src/core/attribution.js"; import type { DiagnosticEvent, RecordListener, RecordType } from "../src/core/dev.js"; // The engine's records arrive on the channel, whose subscriptions are the @@ -717,3 +717,161 @@ describe("at — a router whose request predates the write it wraps", () => { expect(nav.hold!.origin).toBe(nav.origin); }); }); + +describe("interaction — a router that awaited before writing hands the click back", () => { + it("joins the write to the interaction captured in the request, not what is on the stack", async () => { + const { runs } = arm(); + const [location, setLocation] = createSignal("/todos", { name: "location" }); + createRoot(() => createEffect(location, () => {}, { name: "reader" })); + flush(); + // The router's core resolves its loaders outside the graph; by the time + // it publishes, the click's frame is gone. It captured the origin in the + // handler and declares it on the ref. The handler returns the router's + // promise, so the interaction stays open until the write lands. + const navigate = () => { + const captured = OBSERVE!.attribution.currentOrigin(); + return wait(5).then(() => + OBSERVE!.attribution.withOrigin( + { kind: "navigation", name: "/todos/:id", to: "/todos/7", interaction: captured }, + () => setLocation("/todos/7") + ) + ); + }; + await OBSERVE!.attribution.withInteraction(CLICK, navigate); + flush(); + await until(() => runs.some(r => r.nodeName === "reader"), "the reader's re-run"); + const nav = attribution.history("navigation").at(-1)!; + expect(nav.interaction).toMatchObject({ kind: "interaction", name: "click" }); + expect(runs.filter(r => r.nodeName === "reader").at(-1)!.causes[0].origin).toMatchObject({ + kind: "navigation", + interaction: { name: "click" } + }); + // And the click's own record lists the navigation it caused. + expect(attribution.history("interaction").at(-1)!.navigations).toContain(nav); + }); + + it("declared beats ambient: an unrelated interaction on the stack does not claim the write", () => { + arm(); + const [location, setLocation] = createSignal("/a", { name: "location" }); + createRoot(() => createEffect(location, () => {}, { name: "reader" })); + flush(); + let first: ReturnType; + OBSERVE!.attribution.withInteraction({ type: "click", target: "a.first" }, () => { + first = OBSERVE!.attribution.currentOrigin(); + }); + OBSERVE!.attribution.withInteraction({ type: "click", target: "a.second" }, () => { + OBSERVE!.attribution.withOrigin( + { kind: "navigation", name: "/b", to: "/b", interaction: first }, + () => setLocation("/b") + ); + }); + flush(); + const nav = attribution.history("navigation").at(-1)!; + expect(nav.interaction).toMatchObject({ target: "a.first" }); + }); + + it("a request that ran under no interaction declares none, even when the write lands inside one", () => { + arm(); + const [location, setLocation] = createSignal("/a", { name: "location" }); + createRoot(() => createEffect(location, () => {}, { name: "reader" })); + flush(); + // Captured outside any interaction (a timer's navigate()): `undefined`, + // and the key's presence is the declaration. + const outside = OBSERVE!.attribution.currentOrigin(); + expect(outside).toBeUndefined(); + OBSERVE!.attribution.withInteraction(CLICK, () => { + OBSERVE!.attribution.withOrigin( + { kind: "navigation", name: "/b", to: "/b", interaction: outside }, + () => setLocation("/b") + ); + }); + flush(); + expect(attribution.history("navigation").at(-1)!.interaction).toBeUndefined(); + }); +}); + +describe("initial — the route the document arrived on", () => { + it("settles a no-write declaration as committed at frame close, from the time origin", () => { + arm(); + const seen: NavigationEvent[] = []; + on("navigation", e => seen.push(e)); + const before = performance.now(); + // What a router does while building its context: match, no write. + const built = OBSERVE!.attribution.withOrigin( + { + kind: "navigation", + initial: true, + name: "/users/:id", + to: "/users/42", + params: { id: "42" } + }, + () => "context" + ); + expect(built).toBe("context"); + expect(seen).toHaveLength(1); + const [nav] = seen; + expect(nav.initial).toBe(true); + expect(nav.at).toBe(0); + expect(nav.origin.at).toBe(0); + expect(nav.from).toBeUndefined(); + expect(nav.interaction).toBeUndefined(); + expect(nav.writes).toBe(0); + expect(nav.outcome).toBe("committed"); + expect(nav.name).toBe("/users/:id"); + expect(nav.to).toBe("/users/42"); + expect(nav.params).toEqual({ id: "42" }); + // Document start → declaration, on the engine's clock. + expect(nav.settledMs).toBeGreaterThanOrEqual(before); + expect(formatOrigin(nav.origin)).toBe("initial navigation to /users/:id (/users/42)"); + }); + + it("takes the router's own start when it passes one, and ignores from", () => { + arm(); + const at = performance.now(); + OBSERVE!.attribution.withOrigin( + { kind: "navigation", initial: true, name: "/", to: "/", from: "/elsewhere", at }, + () => {} + ); + const [nav] = attribution.history("navigation"); + expect(nav.at).toBe(at); + expect(nav.from).toBeUndefined(); + }); + + it("re-reads the ref at settle, so a lazy match resolved while building lands on the record", () => { + arm(); + const ref: NavigationRef = { + kind: "navigation", + initial: true, + name: "/admin/*", + to: "/admin/users" + }; + OBSERVE!.attribution.withOrigin(ref, () => { + ref.name = "/admin/users"; + }); + expect(attribution.history("navigation")[0].name).toBe("/admin/users"); + }); + + it("is a regular navigation for the records and history, but not for the feedback tables", () => { + arm(); + OBSERVE!.attribution.withOrigin( + { kind: "navigation", initial: true, name: "/users/:id", to: "/users/42" }, + () => {} + ); + // A real navigation to the same route afterwards. + const [location, setLocation] = createSignal("/users/42", { name: "location" }); + createRoot(() => createEffect(location, () => {}, { name: "reader" })); + flush(); + OBSERVE!.attribution.withOrigin(NAV, () => setLocation("/users/43")); + flush(); + expect(attribution.history("navigation")).toHaveLength(2); + const rows = feedback().navigations; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ name: "/users/:id", navigations: 1 }); + }); + + it("is a plain call when no engine is installed", () => { + expect( + OBSERVE!.attribution.withOrigin({ kind: "navigation", initial: true, name: "/" }, () => 7) + ).toBe(7); + }); +}); diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md index 7b6551118..cfc369a70 100644 --- a/packages/solid/skills/reactivity-diagnostics/SKILL.md +++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md @@ -629,7 +629,12 @@ them as `artifact.attribution.feedback` / `.costs` already. fast or preload it on hover/intent; a route that is always `redirected` into is paying a hop the link could skip. `attribution.history("navigation")` lists each navigation with its `outcome`, its `redirects` (the abandoned - destinations) and, when held, the `HoldEvent` itself. + destinations) and, when held, the `HoldEvent` itself. The first entry is + the route the document arrived on when the router declares it (`initial: +true` on the ref, around its initial match): `initial: true`, `writes: 0`, + `at` the time origin — a declaration of the route, not a wait, and not a + row in this table. On the server the same declaration is `RenderEvent.route` + on the request's `"render"` record. - `flights` — one row per async source: `flights` started, `landed`, `abandoned` (superseded by a newer flight before landing), `landedMs`, `worstMs`. A source with many abandoned flights is re-asking on every diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index f1f855334..c3862e46d 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -151,6 +151,10 @@ export { export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; +// After the runtime modules above, so this import adds no edge to the module +// graph's evaluation order (shared.js is long loaded) and the prod artifact +// is unchanged. +import { installServerWithOrigin } from "./shared.js"; /** * @internal — client-only (see client/hydration.ts). The server stub is @@ -174,7 +178,10 @@ export function materializeContainerTrace(marker: unknown): unknown { // regardless of, the web runtime that emits into them. const IS_DEV = "_SOLID_DEV_" as string | boolean; const IS_OBSERVE = "_SOLID_OBSERVE_" as string | boolean; -if (IS_OBSERVE) _OBSERVE!.server = serverSlots(); +if (IS_OBSERVE) { + _OBSERVE!.server = serverSlots(); + installServerWithOrigin(_OBSERVE!); +} export const OBSERVE: Observe | undefined = IS_OBSERVE ? _OBSERVE : undefined; export const DEV: Dev | undefined = IS_DEV ? _DEV : undefined; // The console face is the core's; the repair-guide footer under each first diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts index 4c8dbc975..22ead76c1 100644 --- a/packages/solid/src/server/shared.ts +++ b/packages/solid/src/server/shared.ts @@ -1,6 +1,7 @@ import { getOwner, getNextChildId, getContext, devPeekNextChildId } from "./signals.js"; import type { Context } from "./signals.js"; import type { BoundaryEvent } from "./observe.js"; +import type { NavigationRef, Observe } from "@solidjs/signals"; export type SSRTemplateObject = | { t: string[]; h: Function[]; p: Promise[] } @@ -76,6 +77,17 @@ export type HydrationContext = { * `"boundary"` listener). Absent outside observe builds. */ _recordBoundary?: (event: BoundaryEvent) => void; + /** + * @internal The seam a router's initial-route declaration reaches the + * render's `"render"` record through (`RenderEvent.route`). Set by + * @solidjs/web at render start in observe builds while a `"render"` + * listener exists; the server entry's `OBSERVE.attribution.withOrigin` + * calls it with an `initial` ref (there is no engine on the server — the + * declaration is the whole of what the call does here). The ref is kept + * and read when the render settles, so a match refined during the render + * is what lands. Absent outside observe builds. + */ + _declareRoute?: (ref: NavigationRef) => void; /** * @internal Containment channel for errors surfacing in async resume loops * (boundary retries, flush passes), where nothing is on the stack to catch @@ -175,3 +187,35 @@ export const sharedConfig: SharedConfig = { // safe to touch at top level from every entry order. const IS_DEV = "_SOLID_DEV_" as string | boolean; if (IS_DEV) sharedConfig.devPeekNextContextId = devPeekNextChildId; + +/** + * Gives the core's `OBSERVE.attribution.withOrigin` its one server meaning. + * The server reimplements reactivity and installs no attribution engine, so + * the core's `withOrigin` is `fn()` here; a router calls it the same way on + * both sides, and the initial-route declaration it makes while building its + * context under a render is the fact the server has nowhere else to learn + * — the route a request resolved to, for the request's `"render"` record + * (`RenderEvent.route`). Filed through the render context's `_declareRoute` + * seam, which @solidjs/web installs while a `"render"` listener exists; a + * non-initial ref (nothing writes a location on the server) and a call + * outside a render are `fn()` as before. A host that bundles the runtime + * twice (two copies of this entry over one core) wraps twice, each copy + * reading its own `sharedConfig` — only the copy running the render has a + * context, so the declaration lands once, on that render. Called from the + * server entry under `_SOLID_OBSERVE_`. + */ +export function installServerWithOrigin(observe: Observe): void { + const slot = observe.attribution; + const inner = slot.withOrigin; + observe.attribution = { + get installed() { + return slot.installed; + }, + withInteraction: slot.withInteraction, + currentOrigin: slot.currentOrigin, + withOrigin(ref: NavigationRef, fn: () => T): T { + if (ref.initial === true) sharedConfig.context?._declareRoute?.(ref); + return inner(ref, fn); + } + }; +} diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 815480256..d814a9acf 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -136,7 +136,8 @@ export type { InvocationLive, RenderEvent, RenderListener, - RenderLive + RenderLive, + RenderRoute } from "./observe.js"; // The trace context's types (`getTraceContext()`, `OBSERVE.server.trace`), // with the `ServerObserve.trace` augmentation, for the same reason. diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index b3dea9234..c1644f8bd 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -46,7 +46,8 @@ export type { InvocationLive, RenderEvent, RenderListener, - RenderLive + RenderLive, + RenderRoute } from "./observe.js"; export type { TraceContext, TraceProvider } from "./trace.js"; export type { JSX } from "../jsx/jsx.js"; diff --git a/packages/web/src/observe.ts b/packages/web/src/observe.ts index 7c178bf9f..047f6520a 100644 --- a/packages/web/src/observe.ts +++ b/packages/web/src/observe.ts @@ -174,6 +174,29 @@ export interface RenderEvent { * threw, a stream's uncontained failure wound it down through `onError`. */ outcome: "complete" | "abandoned" | "error"; + /** + * 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 declaration the client's first + * `"navigation"` record comes from): `name` the matched pattern + * (`/users/:id`), `to` the concrete path, `params` what the pattern + * bound. Read from the router's ref when the render settles, so a match + * refined during the render is what lands. Absent when no router declared + * one — a render without a router, or a router that does not yet. What a + * consumer names the request by (`http.route`), where the URL would + * scatter one page across as many names as it has parameters. + */ + route?: RenderRoute; +} + +/** `RenderEvent.route` — the route a render resolved to, as the router matched it. */ +export interface RenderRoute { + /** The matched route pattern — `/users/:id`. */ + name?: string; + /** The concrete path. */ + to?: string; + /** The params the pattern bound (optional params unbound: `undefined`). */ + params?: Readonly>; } /** The live half of a render record. */ diff --git a/packages/web/src/server-observe.ts b/packages/web/src/server-observe.ts index be389d2dd..ca8910c77 100644 --- a/packages/web/src/server-observe.ts +++ b/packages/web/src/server-observe.ts @@ -25,8 +25,10 @@ import { type InvocationEvent, type InvocationLive, type RenderEvent, - type RenderLive + type RenderLive, + type RenderRoute } from "./observe.js"; +import type { NavigationRef } from "solid-js"; import { traceForEvent, type TraceRecord } from "./trace.js"; import type { RequestEvent } from "./server.js"; @@ -61,6 +63,13 @@ export interface RenderObservation { shell(): void; /** A `` boundary the shell waited on settled: counts it (`boundaries`). */ boundary(): void; + /** + * The router declared the render's route (`_declareRoute` on the render + * context): keeps the ref, read at `settle` into `RenderEvent.route`. A + * later declaration replaces an earlier one (a router remounted by a + * boundary's second pass describes the same route again). + */ + route(ref: NavigationRef): void; /** The render ended; delivers the record. Once. */ settle(outcome: RenderEvent["outcome"]): void; } @@ -88,6 +97,7 @@ export function observeRender( }; trace.render = record; let settled = false; + let route: NavigationRef | undefined; return { shell() { // Once, and never on a delivered record (a render wound down as its @@ -97,11 +107,22 @@ export function observeRender( boundary() { if (!settled) record.boundaries++; }, + route(ref) { + if (!settled) route = ref; + }, settle(outcome) { if (settled) return; settled = true; record.durationMs = performance.now() - record.at; record.outcome = outcome; + if (route !== undefined) { + // The ref's getters answer as of now — the router's final match. + const r: RenderRoute = {}; + if (route.name !== undefined) r.name = route.name; + if (route.to !== undefined) r.to = route.to; + if (route.params !== undefined) r.params = route.params; + record.route = r; + } const channel = records()!; if (!channel.observed("render")) return; const live: RenderLive = { trace: trace.context }; diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index c8a3c9f3b..6c8f624e3 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -5613,6 +5613,10 @@ function peekRequestEvent() { function timeDocument(context, trace, mode, requestEvent) { if (!"_SOLID_OBSERVE_" || records() === undefined) return undefined; const render = observeRender(trace, mode, requestEvent); + // The router's initial-route declaration (the server entry's `withOrigin` + // files it here) lands on the render record: under the record's own gate, + // since the record is its only reader. + if (render) context._declareRoute = ref => render.route(ref); if (timesServerWork("boundary")) { context._recordBoundary = event => { trace.timing.push({ type: "boundary", event }); diff --git a/packages/web/test/observe.type-tests.ts b/packages/web/test/observe.type-tests.ts index f918c6ecd..2c7eb9a9b 100644 --- a/packages/web/test/observe.type-tests.ts +++ b/packages/web/test/observe.type-tests.ts @@ -34,6 +34,7 @@ import type { InvocationEvent, InvocationLive, RenderEvent, + RenderRoute, RenderLive, RequestEvent, TraceContext, @@ -97,6 +98,10 @@ observe.records.subscribe("render", (event, live) => { event.durationMs satisfies number; event.boundaries satisfies number; event.outcome satisfies "complete" | "abandoned" | "error"; + event.route satisfies RenderRoute | undefined; + event.route?.name satisfies string | undefined; + event.route?.to satisfies string | undefined; + event.route?.params satisfies Readonly> | undefined; live.trace satisfies TraceContext; live.event satisfies RequestEvent | undefined; }); diff --git a/packages/web/test/server/server-render-route.spec.tsx b/packages/web/test/server/server-render-route.spec.tsx new file mode 100644 index 000000000..daf55ef83 --- /dev/null +++ b/packages/web/test/server/server-render-route.spec.tsx @@ -0,0 +1,175 @@ +/** + * @jsxImportSource @solidjs/web + */ +// `RenderEvent.route`: the route a server render resolved to, as the router +// declared it. A router calls `OBSERVE.attribution.withOrigin` with an +// `initial` ref while building its context — the same call that opens the +// client's first `"navigation"` record — and on the server, where there is +// no attribution engine, the server entry's `withOrigin` files the ref on the +// render context (`_declareRoute`, installed by the renderer under the +// `"render"` record's gate) so the request's `"render"` record names the +// route. The consumer's `http.route`: without it the URL is the only name a +// request has, and one page scatters across as many names as it has params. +// +// Pinned here, against the real renderers: +// +// - the declaration lands on the record for `renderToString` and +// `renderToStream`, from the same call a client router makes; +// - the ref is read at settle, so a match refined during the render (a lazy +// subtree) is what lands, and a later declaration replaces an earlier one; +// - a non-initial ref (nothing writes a location on the server) declares +// nothing, and `withOrigin` still runs its function and returns its value; +// - a render with no router has no `route`; +// - with no `"render"` listener the seam is not installed: the call is +// `fn()` and nothing is kept. +import { afterEach, describe, expect, test } from "vitest"; +import { renderToStream, renderToString } from "@solidjs/web"; +import { OBSERVE, type NavigationRef } from "solid-js"; +import type { RenderEvent, RenderLive } from "@solidjs/web"; + +type Record = { event: RenderEvent; live: RenderLive }; + +const unsubscribes: Array<() => void> = []; +afterEach(() => { + for (const off of unsubscribes.splice(0)) off(); +}); + +function renders(): Record[] { + const seen: Record[] = []; + unsubscribes.push( + OBSERVE!.records.subscribe("render", (event, live) => { + seen.push({ event, live }); + }) + ); + return seen; +} + +function stream(code: () => any): Promise { + return new Promise(resolve => { + const chunks: string[] = []; + renderToStream(code).pipe({ + write(chunk: string) { + chunks.push(chunk); + }, + end() { + resolve(chunks.join("")); + } + }); + }); +} + +/** What a router does while building its context: match, declare, build. */ +function Router(props: { nav: NavigationRef; children?: any }) { + return OBSERVE!.attribution.withOrigin(props.nav, () =>
{props.children}
); +} + +const USER: NavigationRef = { + kind: "navigation", + initial: true, + name: "/users/:id", + to: "/users/42", + params: { id: "42" } +}; + +describe("RenderEvent.route", () => { + test("renderToString: the router's initial declaration names the render", () => { + const seen = renders(); + const html = renderToString(() => hi); + expect(html).toContain(">hi"); + expect(seen).toHaveLength(1); + expect(seen[0].event.route).toEqual({ + name: "/users/:id", + to: "/users/42", + params: { id: "42" } + }); + expect(seen[0].event.outcome).toBe("complete"); + }); + + test("renderToStream: the same, delivered when the stream completes", async () => { + const seen = renders(); + await stream(() => hi); + expect(seen).toHaveLength(1); + expect(seen[0].event.route).toEqual({ + name: "/users/:id", + to: "/users/42", + params: { id: "42" } + }); + }); + + test("the ref is read at settle: a match refined during the render is what lands", () => { + const seen = renders(); + const ref: NavigationRef = { + kind: "navigation", + initial: true, + name: "/admin/*", + to: "/admin/users" + }; + renderToString(() => + OBSERVE!.attribution.withOrigin(ref, () => { + // A lazy subtree resolved inside the render refines the match. + ref.name = "/admin/users"; + ref.params = {}; + return
; + }) + ); + expect(seen[0].event.route).toEqual({ name: "/admin/users", to: "/admin/users", params: {} }); + }); + + test("a later declaration replaces an earlier one", () => { + const seen = renders(); + renderToString(() => ( + + x + + )); + expect(seen[0].event.route).toEqual({ name: "/b", to: "/b" }); + }); + + test("only fields the router gave are present", () => { + const seen = renders(); + renderToString(() => ); + expect(seen[0].event.route).toEqual({ name: "/" }); + }); + + test("a non-initial ref declares nothing, and withOrigin is still the call it was", () => { + const seen = renders(); + let ran = 0; + const html = renderToString(() => + OBSERVE!.attribution.withOrigin({ kind: "navigation", name: "/x", to: "/x" }, () => { + ran++; + return
x
; + }) + ); + expect(ran).toBe(1); + expect(html).toContain(">x
"); + expect(seen[0].event.route).toBeUndefined(); + }); + + test("a render with no router has no route", () => { + const seen = renders(); + renderToString(() =>
plain
); + expect(seen).toHaveLength(1); + expect("route" in seen[0].event).toBe(false); + }); + + test("with no render listener the declaration is a plain call", () => { + let ran = 0; + const html = renderToString(() => + OBSERVE!.attribution.withOrigin(USER, () => { + ran++; + return
x
; + }) + ); + expect(ran).toBe(1); + expect(html).toContain(">x"); + }); + + test("the server entry's withOrigin keeps the slot's other members", () => { + const slot = OBSERVE!.attribution; + expect(typeof slot.withInteraction).toBe("function"); + expect(typeof slot.currentOrigin).toBe("function"); + expect(slot.installed).toBeNull(); + expect(slot.withInteraction({ type: "click", target: "x" }, () => 3)).toBe(3); + expect(slot.currentOrigin()).toBeUndefined(); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 2b3191e65..109f1ea99 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2250,7 +2250,12 @@ module.exports = [ // three signals fixes that landed since the switch was measured (#3678 F3/F5, // #3684 F6, #3682 F1; the esbuild notes above record them), now Rolldown-measured; // cap 16.34 KB, measured rounded up to the next 0.01 kB. - limit: "16.34 KB", + // Initial route declaration (#3683, 2026-09-28): 16.34 -> 16.35 KB, measured + // at 16,342 B against `next` @ 61a33af5a's 16,336 (+6 B; 0 B minified — the + // observe core, solid.observe.js and web.observe.js are unchanged; the change + // is the attribution engine's, whose property set shifts the shared mangler + // layout under this scenario). Every prod scenario byte-identical. + limit: "16.35 KB", alias: observeAlias }, { @@ -2594,7 +2599,18 @@ module.exports = [ // 656f0dff6. Engine-side: the fix lives on the attribution path, and the // observe tier scenario above did not move; cap 30.52 KB, measured rounded // up to the next 0.01 kB. - limit: "30.52 KB", + // Initial route declaration (#3683, 2026-09-28): 30.52 -> 30.54 KB, measured + // at 30,536 B against `next` @ 61a33af5a's 30,513 (+23 B; every prod scenario + // byte-identical, the tier scenario above +6 B of mangler layout). + // `NavigationRef.initial` (the route the document arrived on: the frame + // opens at the time origin, takes no `from`, settles on the existing + // no-write rule, `initial: true` on the event and kept out of the + // feedback fold) and `NavigationRef.interaction` (a router that awaited + // before writing hands back the origin it captured; declared beats + // ambient), plus the `initial ` prefix in `formatOrigin`. The server + // side of the same declaration (`RenderEvent.route`) lives in the server + // artifacts, outside every scenario here. + limit: "30.54 KB", alias: observeAlias }, {