Skip to content

refactor(router): wire protocol module for partial-request parameters (S16) - #1248

Merged
ivogt merged 1 commit into
reorg/stackfrom
reorg/s16-wire-protocol
Oct 23, 2026
Merged

ivogt merged 1 commit into
reorg/stackfrom
reorg/s16-wire-protocol

Conversation

@ivogt

@ivogt ivogt commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: rango-t144 · session rango-s282

Problem

The browser built partial-request URLs in three places: the navigation in browser/navigation-client.ts, the prefetch in browser/prefetch/fetch.ts (buildPrefetchUrl), and the JS server action in browser/server-action-bridge.ts. All three wrote the same sequence of parameters, and they differed in two ways that matter. A prefetch omits an empty _rsc_segments where the other two send it, and an action sends _rsc_action=<id> where the other two send _rsc_partial=true. The server read the names as string literals in about twenty files. _rsc_fill, _rsc_stale and _rsc_shell also had their own constants, and _rsc_shell had two of them: one in the server (SHELL_MISS_PARAM) and a browser copy (SHELL_MISS_MARKER), kept equal by a test. A prefetch is adopted only while both writers produce identical prefetch cache keys, so any drift between them breaks adoption without an error.

Plan step S16 (rango-d195 section 3; target architecture rango-d194 section 3.5). The change preserves behaviour: no consumer-visible change, and the URLs are byte-identical.

Before / after (internal shape)

Before, each sender wrote its own parameters:

fetchUrl.searchParams.set("_rsc_partial", "true");
fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
if (staleRevalidation) fetchUrl.searchParams.set(STALE_PARAM, "true");
if (version) fetchUrl.searchParams.set("_rsc_v", version);
// ... _rsc_rid, _rsc_rt, FILL_PARAM

After, every sender calls one writer:

// src/wire-params.ts (dependency free, both graphs)
export const WIRE = { partial: "_rsc_partial", segments: "_rsc_segments", action: "_rsc_action",
  version: "_rsc_v", routerId: "_rsc_rid", rewriteTarget: "_rsc_rt", stale: "_rsc_stale",
  fill: "_rsc_fill", loader: "_rsc_loader", loaderParams: "_rsc_loader_params",
  shellMiss: "_rsc_shell", warmup: "_rsc_warmup" } as const;

// src/browser/wire-protocol.ts
writePartialParams(url, {
  action?, segmentIds, emptySegments: "send" | "omit",
  stale?, version?, routerId?, rewriteTarget?, fill?,
});
// writes: action | partial, segments, stale, version, routerId, rewriteTarget, fill
Sender emptySegments Other fields
Navigation (navigation-client.ts) "send" stale, version, routerId, rewriteTarget, fill
Prefetch (prefetch/fetch.ts) "omit" version, routerId, rewriteTarget
JS server action (server-action-bridge.ts, URL block only) "send" action, version, routerId, rewriteTarget
serveShellRequest action request (testing) "send" action, rewriteTarget

The plan's sketch had kind: "navigation" | "prefetch" | { action } next to emptySegments. emptySegments already carries the whole difference between navigation and prefetch, so the writer takes action?: string instead.

Code changes

  • New src/wire-params.ts (WIRE, WIRE_PREFIX) and src/browser/wire-protocol.ts (writePartialParams, PartialRequestInit).
  • Writers: browser/navigation-client.ts, browser/prefetch/fetch.ts, and the URL block of browser/server-action-bridge.ts. commitAction and every other Hard rule 10 region are untouched. testing/serve-shell-request.ts buildActionRequest calls the writer too. buildRequest there keeps writing its params by hand, because its order (rt, fill, stale) differs from the browser's.
  • Readers switched from literals to WIRE.*:
    • router and rsc: router/request-classification.ts, router/navigation-snapshot.ts, router/segment-resolution/prefetch-deferral.ts, rsc/handler.ts, rsc/ssr-setup.ts, rsc/response-route-handler.ts, rsc/rsc-rendering.ts, rsc/loader-fetch.ts, rsc/rewrite-passes.ts, rsc/shell-serve.ts
    • cache: cache/cache-scope.ts, cache/document-cache.ts
    • testing: testing/dispatch.ts, testing/serve-shell-request.ts, testing/e2e/page-helpers.ts
    • browser: browser/connection-warmup.ts, browser/history-state.ts, browser/prefetch/cache.ts, browser/prefetch/queue.ts
  • Removed duplicate names: FILL_PARAM and STALE_PARAM (prefetch-markers.ts keeps PREFETCH_HEADER and PREFETCH_FAILED_ROW), SHELL_MISS_PARAM (rsc/shell-serve.ts) and SHELL_MISS_MARKER (browser/history-state.ts). Also removed: the history-state.test.ts case that asserted those two were equal. Both sides now read WIRE.shellMiss.
  • tools/internals-baseline.json is lowered. Rule rsc-param-literal drops from 71 hits in 22 files to 4, all in src/use-loader.tsx. That file is on the Hard rule 10 list, so it stays as it is. The tool exempts packages/rangojs-router/src/wire-params.ts (WIRE_PARAMS_FILE), which is where the new file lives.
  • No lazy split: server-action-bridge.ts and browser/prefetch/ import the writer statically.
  • Source: 27 files, +224 / -192.

Test plan

  • src/browser/__tests__/wire-urls.test.ts (S3): 10/10. The exact navigation, prefetch and action URLs and the prefetch cache keys are unchanged.

  • src/__tests__/prefetch-fetch.test.ts 41/41; src/__tests__/server-action-bridge.test.ts 45/45. The redirect and transition-when bridge suites, history-state.test.ts and the browser tests: 27 files, 501 tests, green.

  • E2e, router test-app, --grep "default-prefetch|prefetch-intercept-source|pending-actions" (checked with --list first):

    • --project=dev --no-deps: 21 passed
    • --project=production --no-deps: 21 passed
  • Client bundle, production build, base vs head. Only assets/router-*.js changes:

    App Raw Gzip (all client JS)
    test-app 149901 -> 150086 B (+185) +54 B
    cloudflare-basic 153954 -> 154133 B (+179) +26 B

    Both are within the 200-byte budget. Rolldown keeps the whole WIRE object in the client chunk, so its two server-only entries (loader, loaderParams) account for about 55 of those bytes.

  • pnpm run typecheck: 16/16 tasks.

  • pnpm run test:unit:all:

    • router unit: 564 files, 9344 passed, 5 skipped, 6 todo
    • router RSC: 83 files, 1065 passed, 3 expected fail
    • mini: 25 + 3
    • vite-rsc-demo: 27 + 1
    • cloudflare-basic: 90 + 39
    • shared-e2e: 20
  • After the review amend (serveShellRequest action writer, loader-fetch message, comments):

    • router src/testing/__tests__ RSC suites: 64 files, 903 passed, 3 expected fail
    • cloudflare-basic RSC: 39/39
    • router tsc: clean
  • oxlint --deny-warnings and oxfmt --check .: clean.

  • CI lint scripts all OK:

    • check-e2e-bucketing, check-e2e-parity --strict, check-e2e-console-baseline
    • check-suspense-contract, check-no-external-store
    • node --test tools/*.test.mjs
    • check-doc-links, check-docs-api, check-skills
    • generate-skills-catalog --check, generate-route-types --check
    • check-internals
  • No route changes; no *.gen.ts diff.

Practices

  • Lint: I ran oxlint --deny-warnings and oxfmt --check . on the change. Both are clean; nothing was silenced.

  • Dead code: I searched the repo (src, tests, e2e, docs, skills) for FILL_PARAM, STALE_PARAM, SHELL_MISS_PARAM and SHELL_MISS_MARKER. Nothing references them any more, and the docs that named them now name WIRE.*. Every WIRE key has a reader. WIRE_PREFIX is used by prefetch/queue.ts. PartialRequestInit stays exported because writePartialParams's signature needs it under isolatedDeclarations.

  • Tests:

    • S3's wire-urls.test.ts is the behaviour proof. It already covers the empty-segments send/omit split, the stale and fill order, the action URL and the prefetch keys, so I added no new test.
    • I removed one test: the equality check between the two shell-miss constants, which now share one name.
    • I ran the touched unit suites, the targeted dev and production e2e, and test:unit:all once.
  • Code review: I reviewed the diff for reuse, simplification, efficiency and altitude. Fixed:

    • serveShellRequest's action request now uses writePartialParams; its order was already identical.
    • loader-fetch's 400 message reads WIRE.loader.
    • I trimmed duplicated comments in shell-serve.ts, history-state.ts, wire-params.ts, prefetch-markers.ts and prefetch/queue.ts.

    Left, recorded on rango-t144:

    • buildRequest's partial order differs from the browser's.
    • Two internal prefixes are in use: _rsc_ here and _rsc in stripInternalParams, cache-key-utils.ts and testing/dispatch.ts.
    • The __rsc, __html and __no_cache params sit outside WIRE.
    • The read side still repeats the stale === "true" check.
    • The two server-only WIRE entries ship in the client chunk.
    • use-loader.tsx keeps its literals (Hard rule 10).

    None of these is a behaviour defect.

  • Docs: docs/internal/feature-map.md has a new "Partial-request wire protocol" entry and the shell-miss marker renamed. docs/internal/feature-file-map.md has a new row and an updated prefetch-markers note. docs/design/prefetch-false.md names WIRE.fill.

Notes

  • src/use-loader.tsx keeps 4 _rsc_loader / _rsc_loader_params literals in its loader-fetch URL build. The literals sit outside commit timing, but the whole file is on the Hard rule 10 list. Moving them is a separate one-line change once the owner agrees.
  • Observed in the dev e2e log, with all tests passing: two browser MISSING SEGMENT ... not in cache console errors during pending-actions. The same two errors appear on the base in the same test (dev, 21/21 on both), and both are listed in tools/e2e-console-baseline.json as intermittent on main: not a regression.
  • No hot-path timing: the server readers swap a string literal for a property read on a module constant. The number of searchParams calls is unchanged.

…ters

The `_rsc_*` query parameter names now live in one table, `WIRE` in
src/wire-params.ts, imported by both graphs. The navigation, prefetch and
JS server action URLs are written by one function,
browser/wire-protocol.ts `writePartialParams`; its `emptySegments`
option keeps the prefetch's omitted empty `_rsc_segments`. The
serveShellRequest action request uses the same writer.

Every server reader and the testing primitives read `WIRE.*`. The
duplicated names go: `FILL_PARAM` and `STALE_PARAM` (prefetch-markers.ts),
`SHELL_MISS_PARAM` (rsc/shell-serve.ts) and its browser copy
`SHELL_MISS_MARKER` (browser/history-state.ts), with the test that kept
those two equal. Parameter names, values and order are unchanged;
browser/__tests__/wire-urls.test.ts pins the exact URLs and prefetch keys.

check-internals rsc-param-literal: 71 hits in 22 files down to 4, all in
use-loader.tsx (a Hard rule 10 file, left as it is); baseline lowered.
@ivogt
ivogt merged commit 8f2a843 into reorg/stack Oct 23, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/s16-wire-protocol branch October 23, 2026 17:59
ivogt added a commit that referenced this pull request Oct 23, 2026
Brings in S16 (#1248, wire-params.ts WIRE), S5 (#1245, deriveRenderPass),
S15 (#1243, discovery phases), #1246 and #1247.

Conflicts, each resolved by keeping S14's structure and applying S16's
intent to it:

- rsc/handler.ts imports: kept the request-stages.js import; dropped the
  classifyRequest, RequestPlan and INTERNAL_RANGO_DEBUG imports S14 had
  already moved out; added S16's WIRE import, still read by the code that
  stayed in the handler (warmup, loader refuse, redirect tracking mode,
  isPartialAction).
- rsc/handler.ts response tail: kept the finalizeRequestResponse call; the
  inline partial/action interception S16 rewrote lives in
  request-stages.ts and now reads WIRE.partial and WIRE.action.
- rsc/handler.ts terminal plans: kept the terminalPlanResponse call; the
  action-rewrite check, partial redirect and version-mismatch log S16
  rewrote live in request-stages.ts and now read WIRE.action, WIRE.partial
  and WIRE.version.
- testing/dispatch.ts imports: added WIRE; dropped applyStreamIdleTimeout,
  which the stages now call.
- testing/dispatch.ts isPartial: kept S14's shape (the reverse and isAction
  moved into the stages) with S16's WIRE.partial.
- tools/internals-baseline.json: took the stack's version;
  check-internals --update leaves it unchanged (rsc-param-literal 4, all in
  use-loader.tsx).

request-stages.ts has no `_rsc_*` literal left. The handler and stages
together make the same searchParams calls as S16's handler.ts: has() on
action x3, partial x3, loader and warmup; get() on loader and version.
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