Skip to content

fix(router): the segment walker meters, spans and warns the same on a full match and a navigation (rango-t187) - #1285

Merged
ivogt merged 4 commits into
reorg/stackfrom
reorg/t187-walker-parity
Oct 24, 2026
Merged

ivogt merged 4 commits into
reorg/stackfrom
reorg/t187-walker-parity

Conversation

@ivogt

@ivogt ivogt commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: rango-t187 · session rango-s397

Problem

A client navigation reported a page's handlers differently from the document request for the same page. The one segment walker (resolveAllSegments) still carried four reporting branches from the old full-match walker:

  • On a navigation or an action revalidation, a parallel slot's handler had no handler:<id> Server-Timing row (debugPerformance) and no rango.handler span (tracing).
  • On a navigation, an orphan layout's handler (a layout among a route's children, or after a bare cache()) had no handler:<id> row.
  • On a navigation, a route served from the static store (Static()) got a handler:<id> row for a handler that did not run. The document request sent no row, as docs/telemetry.md documents.
  • In development, a slot handler under loading() that returned a Response (for example redirect()) printed the "returned a Response … NOT sent as an HTTP redirect" warning on navigations only, not on document loads.

These are reporting and dev-warning gaps, not rendering defects. walker-differential.test.ts (#1279) pinned them as five named exceptions. The review of #1279 judged them in rango-t162-c2.

One more item (rango-t184-c4): on a cache() or prerender HIT, withCacheLookup dropped the entries that an intercept's fill does not hold before it called resolveLoadersOnly. The ppr header latch inside resolveLoadersOnly could then read a chain without its route leaf. Nothing visible depends on this today.

Before / after (consumer view)

Same page, /slots (a layout with parallel({ "@side": … })), with createRouter({ debugPerformance: true, tracing }):

# before
document   Server-Timing: …, d2-handler-…, d2-handler-…, d2-handler-…parallel…side, …
navigation Server-Timing: …, d2-handler-…, d2-handler-…, …          (no slot row, no slot span)

# after
document   Server-Timing: …, d2-handler-…, d2-handler-…, d2-handler-…parallel…side, …
navigation Server-Timing: …, d2-handler-…, d2-handler-…, d2-handler-…parallel…side, …
Handler Document, before Navigation or action revalidation, before Every request, after
Parallel slot handler row + span none row + span
Orphan layout handler row + span span only row + span
Route served from the static store none row none
Streamed slot handler returns a Response (dev) no warning warning warning
Chain layout or route handler row + span row + span unchanged
Layout served from the static store row row unchanged (as telemetry.md documents)

What renders is unchanged. No change in your code. A dashboard that reads the per-segment rows of navigations now sees the slot and orphan rows.

End-to-end example: a consumer test that holds the document and the navigation to the same report:

const router = createRouter({
  debugPerformance: true,
  tracing: createOTelTracing(tracer),
}).routes(
  urls(({ path, layout, parallel }) => [
    layout(
      () => <section>slots</section>,
      () => [
        parallel({ "@side": () => <aside>side</aside> }),
        path("/slots", () => <p>slots page</p>, { name: "slots" }),
      ],
    ),
  ]),
);

const doc = await serveShellRequest(router, "/slots");
const nav = await serveShellRequest(router, "/slots", {
  partial: { from: "/home" },
});
// The same d2-handler-* rows and the same rango.handler spans on both.

Code change

  • src/router/segment-resolution/revalidation.ts
    • resolveParallelSlotComponent: always track("handler:<parallel id>.<slot>"), always run the handler through observeSegmentHandler, which keeps the slot's tag scope and adds the span. Always warnOnStreamedResponse on a streamed result. The runInSegmentTagScope import goes.
    • resolveOrphanLayout: always meter the orphan's handler.
    • resolveRouteComponent: meter after the static lookup on every pass, so a static hit records nothing. The fresh parameter goes.
    • resolveLoadersOnly: skip an entry an intercept's fill does not hold (interceptFillSkips) in its entry loop, after latchPprHeaderScopeForEntries, as resolveAllSegments does.
  • src/router/match-middleware/cache-lookup.ts: pass the whole chain below the boundary to resolveLoadersOnly, with no fill filter.
  • docs/telemetry.md: the handler:{id} row says slot and orphan handlers are recorded on every pass. CHANGELOG.md: an Unreleased "Fixed" entry.

Net non-test source: -15 lines (revalidation.ts +27/-40, cache-lookup.ts +1/-3).

Each branch now follows the full match's behaviour, except the static hit. There the full match matches the documented contract (docs/telemetry.md: a route's or slot's static hit records neither row nor span), so the partial pass moves to it. Every other branch moves the partial pass to what the document already reported.

Test plan

  • walker-differential.test.ts: the five recorded exceptions are removed (slot handler metered, slot handler span, slot Response warning, orphan handler metered, static hit metered). Red before the fix: 12 failed / 36 passed of 48. After the slot change: 8 failed. After the orphan change: 2 failed. After the static-hit change: 48 passed.
  • New userland test src/testing/__tests__/walker-observability-parity.rsc-test.tsx, through serveShellRequest. It pins that a navigation reports a slot handler's Server-Timing row and rango.handler span, that an orphan layout and its slot have the same rows and spans on a navigation as on the document, and that a streamed slot Response is warned about on the document as on a navigation (dev). Red on the base walker: 3 of 3 failed. With the fix: 3 of 3 passed.
  • cache-hit-parallel-loaders.test.ts, new case: on an intercept's fill, resolveLoadersOnly skips an unheld entry and still latches ppr from the route leaf. Red on the base walker (L1D0.unheld-loader resolved). With the fix: 9 of 9 passed.
  • vitest run src/router src/cache: 187 files, 3057 passed, 5 skipped, 3 todo.
  • Semantic matrix, dev and production (playwright test semantic-matrix): 111 passed (dev 43, production 46, plus the smoke, build and warmup setup projects).
  • Pre-push gate: typecheck, test:unit and test:unit:rsc (every package), oxlint --deny-warnings, oxfmt --check, build, and the CI lint checks (check:e2e-bucketing, check:e2e-parity --strict, check:e2e-console-baseline, check:suspense-contract, check:no-external-store, tools tests, check:doc-links, check:docs-api, check:skills, check:skills-catalog, check:route-gen, check:internals, check:bundle-guards): all pass.

Practices

  • Lint: oxlint --deny-warnings and oxfmt --check ran on the whole repo and are clean. The docs/telemetry.md table was reflowed by the formatter.
  • Dead code: the change leaves runInSegmentTagScope unused in revalidation.ts (removed; instrument.ts, intercept-resolution.ts, cache-scope.ts and cache-tag.ts still use it). resolveRouteComponent's fresh parameter and the slot function's fresh local are removed. interceptFillSkips stays imported in cache-lookup.ts, which still uses it for stored segments. The differential's Kind owner map stays, because it still names a difference by its segment id.
  • Tests: the touched suites ran, plus three new tests (above), each shown red before and green after. The semantic matrix ran in dev and production. The full e2e suite runs on CI.
  • Code review: I read the diff by hand. On a navigation, a slot handler now runs inside observeSegmentHandler, which is runInSegmentTagScope plus the span; with tracing off it is the same direct call as before. The extra track() per slot and orphan is a no-op without debugPerformance. Nothing was left open.
  • Docs: docs/telemetry.md (the handler:{id} row) and the CHANGELOG are updated. execution-model.md already describes the streamed-Response warning for every streamed handler, and the observability skill makes no claim about slot rows. Neither needs a change.

Review follow-ups

The review found two of the CHANGELOG's reporting claims pinned only as "the two passes agree". A change that broke both passes the same way would have passed every suite. One test commit (e245dd2) pins each claim on its own, and a merge brings the branch up to reorg/stack.

  • A static hit is never metered. walker-differential's "route served from the static store" case now lists the segments each walk meters (metered: ["M0L0", "M0L0L0"], both walks), so the route (M0L0L0R9) must be absent from both. Mutation, track() moved above tryStaticHandler in resolveRouteComponent so both passes meter the hit: 2 failed / 46 passed (both partitions). Without it: 48 passed.
  • An orphan layout is always metered. walker-observability-parity's orphan case asserts the orphan layout's d2-handler-…orphanlayout<n> row on the document and on the navigation. walker-differential's orphan case lists its metered segments (the orphan layout M0L0L0R7L0 and its slot included). Mutation, the orphan's track() removed from both passes: the parity test fails 1 / passes 2, and walker-differential fails 2 / passes 46. Without it: 3 of 3 and 48 of 48 pass.
  • CHANGELOG: the entry now says a Static() route's row disappears from navigations; documents never had it.
  • Merge: origin/reorg/stack (8d566af) is merged in. The only conflict was CHANGELOG.md, resolved by keeping both entries.
  • Gate: re-run at the new head; all 20 steps pass (typecheck, both unit configs, lint, format, build, every check:* script and the bundle guards).

Notes

  • The void-layout undefined difference stays in the differential as an exception. It is not a defect (rango-t162-c2).
  • The intercept fill skip move changes no output today. It puts the loaders-only walk in the same order as the full walk: latch first, then skip.
  • An open owner question from rango-t184-c4 is not addressed here: whether an intercept loader's header write on a ppr target should be refused on MISS and HIT.

ivogt added 2 commits October 24, 2026 12:35
…and warns about a streamed slot Response, as the document does (rango-t187)

The one segment walker kept four reporting branches of the old full-match
walker. A navigation or an action revalidation gave a parallel slot's
handler no handler:<id> Server-Timing row and no rango.handler span, and an
orphan layout's handler no row; it metered a route's static-store hit as a
handler run; and only it warned in development when a streamed slot
handler returned a Response.

Both passes now meter and span every handler they run, meter no static hit
(docs/telemetry.md: a route's or slot's static hit records neither), and
warn on a streamed slot Response. What renders is unchanged.

walker-differential drops its five recorded exceptions ("slot handler
metered", "slot handler span", "slot Response warning", "orphan handler
metered", "static hit metered"). walker-observability-parity.rsc-test.tsx
pins the same rows, spans and warning through serveShellRequest.
…nly, after the ppr latch (rango-t187)

withCacheLookup filtered the entries an intercept's fill does not hold
before it called resolveLoadersOnly, so the ppr header latch there read a
chain that could lack the route, its leaf. resolveAllSegments skips those
entries itself, after its latch. resolveLoadersOnly now does the same
(interceptFillSkips in its entry loop) and withCacheLookup passes the
whole chain below the boundary. The same entries are skipped; nothing a
consumer sees changes today.
ivogt added 2 commits October 24, 2026 13:04
…ker-parity

# Conflicts:
#	packages/rangojs-router/CHANGELOG.md
…yout always is, on both passes (rango-t187)

walker-differential's static-hit and orphan cases now list the segments
each walk meters (`metered`), not only that the two walks agree: the
static-hit route is absent on both, the orphan layout and its slot present
on both. walker-observability-parity's orphan case asserts the orphan
layout's d2-handler row on the document and on the navigation. The
CHANGELOG entry says a Static() route's navigation row disappears.
@ivogt
ivogt merged commit d97a14f into reorg/stack Oct 24, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/t187-walker-parity branch October 24, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant