Skip to content

feat(router): clientUrls() — client route groups with instant navigation, server authority - #812

Merged
ivogt merged 26 commits into
mainfrom
feat/client-urls
Jul 26, 2026
Merged

ivogt merged 26 commits into
mainfrom
feat/client-urls

Conversation

@ivogt

@ivogt ivogt commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

A soft navigation to a client-component route shows nothing until the partial Flight response lands. An app that wants instant destination feedback — a skeleton, a busy layout, a modal over the current page — has no way to give the browser that route topology without also giving up server authority over matching, middleware, loaders, and redirects. There was also no way to express route-level UI concerns (intercept modals, view-transition config, per-loader revalidation) for a group of client routes co-located in one module.

clientUrls() gives the hydrated browser a definition-local route map for presentation only. The server keeps full authority: it matches every request, runs middleware and loaders, and only the canonical response commits URL, history, and content.

Before vs. after

Before — client-component routes are ordinary server path() entries; the browser knows nothing until the response arrives:

// urls.tsx (server module)
export const urlpatterns = urls(({ path, loader, loading }) => [
  path("/products/:productId", <ProductPage />, { name: "product" }, () => [
    loader(ProductLoader),
    loading(<ProductLoading />),
  ]),
]);
// Navigation: click -> (network) -> content. Nothing on screen changes until the payload lands.

After — the group lives in one "use client" module; the browser presents instantly, the server still decides:

// catalog.client-urls.tsx
"use client";
export default clientUrls(({ path, layout, loader, loading }) => [
  layout(CatalogLayout, () => [
    path("/products/:productId", ProductPage, { name: "product" }, () => [
      loader(ProductLoader),
      loading(<ProductLoading />),
    ]),
  ]),
]);

// urls.tsx — composition is the baseline mounting model
export const urlpatterns = urls(({ include, layout }) => [
  layout(<CatalogRscLayout />, () => [
    include("/catalog", catalogClientUrls, { name: "catalog" }),
  ]),
]);
// Navigation: click -> destination loading() renders immediately, wrapping
// layouts get useOutlet().pending === true -> canonical response commits.

End-to-end consumer example

See packages/rangojs-router/docs/client-urls.md for the full copy-pasteable walkthrough (loader module, client module, mount, router). The short form:

// src/catalog.loader.ts — ordinary non-fetchable server loader
export const ProductLoader = createLoader(async (ctx) => getProduct(ctx.params.productId));

// src/catalog.client-urls.tsx
"use client";
import { clientUrls, useLoader, useOutlet, useParams } from "@rangojs/router/client";
import { ProductLoader } from "./catalog.loader.js";

function CatalogLayout() {
  const { content, pending } = useOutlet();
  return <main aria-busy={pending}>{content}</main>;
}
function ProductPage() {
  const { data: product } = useLoader(ProductLoader);
  return <h1>{product.name}</h1>;
}

export default clientUrls(({ path, layout, loader, loading, intercept, transition, revalidate }) => [
  layout(CatalogLayout, () => [
    path("/", CatalogIndex, { name: "index" }),
    path("/products/:productId", ProductPage, { name: "product" }, () => [
      loader(ProductLoader),
      // Session pattern: load once; the predicate runs IN THE BROWSER and only
      // its decision crosses the wire.
      loader(SessionLoader, () => [revalidate(() => false)]),
      loading(<ProductLoading />),
      transition({ name: "product", viewTransition: "auto" }),
    ]),
    path("/products/:productId/reviews", ReviewsPage, { name: "reviews" }),
  ]),
  // Module-local intercept: same-group navigations to `product` present a modal
  // in the "@modal" slot; direct visits render the full page.
  intercept("@modal", ".product", ProductModal, () => [loader(ProductLoader)]),
]);

What shipped (consumer surface)

  • clientUrls() from @rangojs/router/client — path, layout, loader, loading, plus intercept (module-local, dot-target, named routes only), transition (data-only config: enter/exit/update/share/default/name/viewTransition; no when), and revalidate (client-run per-loader predicates). include, parallel, cache, error(Boundary), notFound(Boundary), and middleware are rejected with clear errors.
  • Composition as the baseline — the definition mounts through include() in the canonical urls() tree: URL/name prefixes, wrapping RSC layouts, and prefix-scoped middleware all derive from the server tree. createRouter().routes(clientUrlPatterns) is pure-client sugar that normalizes to a root include.
  • Instant navigation presentation — destination loading() renders immediately on a local match; wrapping client layouts see useOutlet().pending. Presentation declines when an intercept claims the target (the modal commits over the origin instead).
  • Server projection + materialization — Vite discovery serializes the definition (patterns, names, loader ids, loading/transition/intercept presence) from the "use client" module; the router materializes ordinary urls() entries with loader stubs that execute registered createLoader() definitions by generated id. Hard requests, SSR, and hydration use the same projected server routes.
  • Client-run per-loader revalidation — loader(Def, () => [revalidate(fn)]) predicates execute in the browser on both navigations and action POSTs; only decisions cross, via the X-Rango-Client-Reval header ({skip, force} by loader id). Requests that cannot carry decisions (no-JS, PE forms, prefetch, document loads) get the locked defaults.
  • Locked revalidation semantics are unchanged. This PR consults predicates; it never flips a default.

Semantics

Case Behavior
Soft nav, local match, destination has loading() Loading renders immediately; layouts get pending: true; canonical response commits
Soft nav, local match, no loading() Current content stays; pending: true; canonical response commits
Soft nav to an intercept-claimed target Optimistic presentation declines; server commits the modal over the origin
Hard request / SSR / no-JS Projected server routes render normally; no client machinery involved
Server redirect/error/middleware short-circuit Always wins; browser match cannot authorize or suppress anything
Action POST over a group Locked defaults: route-owned loaders revalidate, parent RSC chain skips (predicates consulted); client revalidate() decisions ride the POST
Same-route param/search nav Locked default: route loaders revalidate; client decisions ride the GET
Stale popstate restore stale: true flows into predicates; defaults unchanged
revalidate() predicate throws Fails open to the locked default (console.error)
Unnamed auto-name segments in nested async-include mounts Documented limit — names must be deterministic; named segments compose

Code-change summary

93 files, +9,252/−643. Core:

  • src/client-urls/ — the feature module: client-urls.ts (DSL + validation), types.ts, server-projection.ts (projection, materialization, synthesized origin-when for intercepts, per-loader decision revalidate() on stubs), client-root.tsx (ClientUrlsRoot, group layout, intercept slots), navigation.ts (local matcher registry, optimistic presentation, decision collection), revalidation-protocol.ts (header codec).
  • src/vite/discovery/ — "use client" module scan, projection recording/refresh, HMR invalidation of entry/router/manifest modules (fixes stale-route 404s on client-module edits).
  • src/browser/partial-update.ts, src/browser/server-action-bridge.ts, src/browser/navigation-client.ts — decision computation and header attach on both request kinds.
  • src/outlet-provider.tsx, src/outlet-context.ts, src/client.tsx — useOutlet().pending channel.
  • Materialized intercepts wrap the group in one layout entry owning the intercept and its slot outlets — module-top-level attachments to the isolated lazy-include parent clone are discarded (getIsolatedLazyParent), and findInterceptForRoute walks the target chain origin-blind, so materialization synthesizes the origin scope the DSL deliberately does not expose.

Test plan

  • Unit: src/client-urls/__tests__/ — DSL shape/validation pins (intercept/transition/revalidate forms), projection + materialization (attachment, when-synthesis mount scoping, header honoring incl. malformed degradation), client-root rendering and decision collection (skip-on-action, force-on-stale, default elision, off-mount, fail-open).
  • E2e dev + production (e2e/client-urls.test.ts, 9 scenarios each): SSR + hydration, instant loading + pending scope, basename, intercepts (server-page origin, same-group, module-local), transition hold vs re-stream, action revalidation (one action → loader refreshes, session predicate holds, parent RSC layout holds), decision transport on same-route param navs.
  • HMR e2e (e2e/client-urls-hmr.test.ts, CI-skipped watcher category, verified locally).
  • Semantic matrix green (75 rows).
  • Gate: pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format — green on every pushed commit.
  • Userland renderRoute coverage for clientUrls() definitions — open follow-up (needs a renderRoute extension to accept a definition; tracked as the remaining DoD item for this feature).

Notes

  • Security boundary is documented: optimistic loading renders before middleware auth — loading branches must not contain protected data; the browser match is never evidence of access (docs/client-urls.md).
  • Known limits, all documented: clientUrls() includes live in statically imported urls modules; nested async-include mounts require named segments (auto-name counter divergence); history-cache back/forward keeps a stale intercept-target set until the next commit.
  • Follow-up in flight on a separate branch: streaming loader data with implicitly-suspending useLoader (spiked green end-to-end; four integration points identified — SSR fallback emission, error-boundary routing, meta-handle collection, PPR live holes).

ivogt added 12 commits July 24, 2026 22:47
…er authority

Client routes defined in a "use client" module: after hydration a local
match renders the destination loading() and useOutlet().pending during
soft navigation while the canonical partial Flight request stays
authoritative. A JSON server projection keyed by the client-reference
$$id materializes ordinary urls() mounts (multi-mount manifests via
mergeFullManifests, shared trie builder), so hard loads, SSR, and
hydration ride the normal pipeline; no route table ships to the browser.

- useOutlet() now returns { content, pending } (breaking; docs + skills
  migrated); OutletProvider carries descendant pending state
- renderRoute gains outletPending seeding for the useOutlet read path
- route-type generation resolves default imports and merges every
  .routes() mount in registration order
- clientUrls() must be the FINAL .routes() registration (throws
  otherwise); direct non-reference registration warns in dev; projection
  subscriptions are disposed on registration and HMR replacement
- dev HMR: clientUrls edits re-run discovery — watcher use-client
  carve-out, pre-entry projection refresh, importer-chain + manifest
  virtual-module invalidation; pinned by the local-run client-urls-hmr
  suite (verified with route-types-hmr, 18 passed)
- dev+prod e2e in test-app and cloudflare-basic; unit coverage for
  projection, materialization, trie sharing, and manifest merging
clientUrls() now participates in the canonical urls() tree through
include(), like any route module: the include supplies the URL and
route-name prefixes, surrounding layouts stay ordinary RSC layouts, and
nested middleware, loaders, boundaries, and route ownership derive from
the server tree. Direct createRouter().routes(clientUrlPatterns)
registration is rejected (compile error and runtime throw), and the
one-definition-per-router and register-last rules are gone — client
definitions follow normal include semantics.

- include() detects clientUrls sources (before the provider check — a
  client reference is a callable proxy) and substitutes a lazily
  materializing urls() handler; materialization resolves the
  discovery-installed projection at evaluation time, so registration
  order never matters
- patterns are definition-local: ClientUrlsRoot reads useMount() and
  navigation strips the include mount prefix before consulting the
  module trie, mirroring client href()/useMount semantics
- discovery projects RECORDED clientUrls modules (transform-time
  source registry) instead of router references; the deferred
  projection-subscription machinery is deleted; deleted sources
  self-heal out of the record map
- cloudflare-basic: the separate clientUrlsRouter and worker dispatch
  are removed — the pure client group is include("/__client-urls") in
  the main urlpatterns, and the mixed example is a real RSC layout
  wrapping include("/mixed-client-routes", clientUrls); every hop is
  now a soft navigation (reloadDocument dropped)
- test-app mounts include("/client-urls-e2e", ...) in urls.tsx; the
  second .routes() call is gone
- gen files are byte-stable across the pivot (same names and patterns
  through the include static resolution)
- docs corrected: composition is not deferred; next exploration is
  suspending useLoader() in client route components

Verified: full unit + RSC suites, client-urls e2e dev+production in
both apps, client-urls-hmr and route-types-hmr (18) local runs,
typecheck all packages, lint, format, bucketing/parity/docs guards.
…, deterministic projection scan

.routes(clientUrlPatterns) is accepted again — as sugar that NORMALIZES
to a root include in the canonical urls() tree (exactly
include("/", definition, { name: "" })): bare route names, basename
inherited like any registration, same lazy materialization. It is not a
second registration path; none of the removed deferral/ordering/
one-definition machinery returns. Pinned by a parity test asserting the
sugar's manifest equals a hand-written root include under urlPrefix.

- basename composes with clientUrls includes by construction
  (registration urlPrefix -> EntryData.mountPath -> MountContext ->
  useMount -> mount-prefix strip); pinned by a mount-relative unit test
  and a new basename-hmr Step 5 driving SSR + gated soft-nav
  presentation under /app in a real browser
- projection discovery gains a deterministic filesystem scan
  (scanForClientUrlModules) so recording never depends on module-graph
  timing; deleted sources self-heal out of the record map
- route-types-hmr baselines now read the git INDEX, not HEAD — the HEAD
  read silently reverted staged fixture edits in afterAll (it clobbered
  the clientUrls include out of test-app urls.tsx before the previous
  commit; that include is restored here)
- docs updated: include() remains the composition model; the direct
  form is documented as the pure-client shorthand

Verified: full unit + RSC suites, client-urls e2e dev+production in
both apps, hmr-client-urls, route-types-hmr (18), hmr-basename with the
new Step 5, typecheck all packages, lint, format, guards.
…idate and the async-include boundary

Pins the composition contract around clientUrls() includes:

- intercept/parallel/revalidate cannot smuggle in through cast option
  objects (projection allowlist rejection pinned per name; the eleven
  DSL helper rejections were already pinned in client-urls.test.ts)
- parallel() slots are handler-valued — a client include is structurally
  unrepresentable inside one; revalidate() has no surface to attach to
  projected routes (generated use-lists); intercept() must not target a
  client route pattern (uncoordinated presentations) — all documented
- async include(() => import()) modules must NOT mount clientUrls
  groups: verified end-to-end that the manifest composes correctly
  (projection discovery is import-timing independent) but runtime lazy
  expansion of a nested substituted include does not reproduce
  discovery's auto-name identity, so requests 404. Locked as a
  documented limit; a mechanics-level unit test pins the manifest half
  so lifting the limit only needs the runtime half

Verified: router unit 5688, client-urls e2e dev+production, lint,
format, typecheck, docs guard.
…d local presentation

Intercepts now work for clientUrls() targets as they do for server
routes. Two origin cases, both e2e-pinned dev+production:

- server-page origin: no client group is mounted, so the canonical
  response commits the modal over the origin like any intercept —
  worked by construction, now pinned (materialized client routes are
  ordinary named entries, and findInterceptForRoute matches by name)
- same-group origin: the browser-local matcher DECLINES its optimistic
  presentation for intercept-claimed targets. MatchResult gains
  interceptTargets (chain walk of the matched entry via
  collectInterceptTargetNames, when-conditionals included), shipped in
  payload metadata on full and partial renders; materialization injects
  the include namePrefix into ClientUrlsRoot so the browser composes
  the local record's canonical name and checks membership. The origin
  stays untouched until the modal commits — no loading flash-and-revert

Conservative on when-conditional intercepts (selectors need live
navigation context): a non-intercepted navigation may lose optimistic
loading, never the reverse. Known narrow edge: history-cache
back/forward restores keep the previous location's target set until the
next fresh commit.

Fixture: named clientUrls group + wrapping-layout intercept + server
origin page in test-app; unit coverage for the decline path, canonical
name composition, and registry clearing; docs flipped from prohibition
to the supported contract.

Verified: router unit 5690 + RSC 71, client-urls e2e dev+production in
both apps (4 tests test-app, 3 cf-basic), turbo typecheck 16/16, lint,
format, guards.
…mit narrowed to unnamed auto-names

The async-include restriction was mis-scoped: the nested-lazy 404 came
from counter-allocated AUTO-names ($prefix_N include scope ids,
$path_* route names) diverging between discovery's single-pass walk
and runtime nested lazy expansion — not from async includes as such.
With explicit names on the wrapping include(s) and the client routes,
every name is deterministic, nothing diverges, and the mount works end
to end (named async include -> named nested include -> named clientUrls
routes), pinned by e2e in dev + production and by the composed names in
the regenerated gen file (asyncClient.nested.*).

Docs now state the precise rule: nested clientUrls includes (including
async modules) require named segments; root-module mounts work with or
without names.

Verified: router unit 5690, client-urls e2e 5/5 dev + 5/5 production,
turbo typecheck 16/16, lint, format, docs guard.
…tion pattern

The server-tree declaration reads as arbitrary on first encounter; the
doc now names the reason (projection boundary — when selectors,
middleware, and server handlers cannot cross from a "use client"
module), shows the co-located server-module pattern that keeps modal
wiring next to its routes, and notes the restricted client-declared
form as a candidate projection-contract extension.
…ocal form

clientUrls() gains intercept(slot, ".localTarget", Component, use?): the
target is a dot-local NAMED route in the same definition and use may contain
loader()/loading() only — no when, no middleware — so every field projects as
JSON across the "use client" boundary.

Materialization solves two server-side mechanics (see materializeRouteItems):

1. Group-layout attachment. findInterceptForRoute walks the TARGET route's
   parent chain, and intercepts emitted at the top level of a lazily included
   module attach to the isolated parent clone (getIsolatedLazyParent) and are
   silently discarded. When a definition declares intercepts, the group is
   wrapped in one materialized layout that owns them and renders the child
   outlet plus each declared slot — the modal presents inside the group's own
   subtree.

2. Synthesized origin `when`. The target-chain walk is origin-blind; a bare
   intercept would claim every origin, and for an out-of-group origin the
   slot's host layout is not in the rendered tree, so the modal would vanish.
   Materialization synthesizes the module-local origin check (pathname inside
   the include's mount) as a server-generated when selector.

Coverage: DSL validation + record collection (client-urls.test.ts),
projection/attachment/when-scoping units (server-projection.test.tsx),
slot/loading/group-layout rendering (client-root.test.tsx), and a dev+prod
e2e pinning module-local scoping end to end (modal from an in-group origin,
full route from an outside origin, full route on hard load). Definitions
without intercepts keep the flat materialized shape.

Docs: client-urls.md documents the two declaration forms and the scoping
contract; feature-map/feature-file-map updated (including removing the stale
".routes() rejects clientUrls directly" line — .routes(def) is the root-include
sugar).
clientUrls() gains transition(config) in path-use position: the data subset
of TransitionConfig — ViewTransition classes (enter/exit/update/share/
default), name, and the viewTransition: "auto" | false boundary opt-out. The
`when` gate is a server-executed predicate and is rejected with a pointed
error — declare it with a server-tree transition() wrapping the include.
startTransition needs no opt-in: the local presentation already wraps its
swaps, and the canonical commit becomes transition-driven once the config is
present.

Serialization carries the config as plain JSON on the projection route
(optional field, absent pre-support); materialization re-emits
transition({...}) in the standard child position, so the route entry gets the
config and the segment system applies the hold (and, on experimental React,
the ViewTransition boundary).

Coverage: DSL validation incl. when/position/duplicate rejection
(client-urls.test.ts), projection + materialized entry.transition
(server-projection.test.tsx), and a dev+prod e2e pinning the observable on
stable React: a same-route param nav on the transition route holds previous
content (MutationObserver flash detection, no loading() skeleton), while the
transition-less twin route re-streams its skeleton.

Docs: client-urls.md supported-surface section; feature-map DSL line.
…s group

No behavior change — coverage pinning the locked revalidation mechanics as
they apply to clientUrls: the group's projected loaders are its server-side
revalidation surface (declared on the client layout, flattened into the route
record), and the action follow-up re-runs them (route-owned default true),
while a parent-chain RSC layout with no revalidate() keeps the
action:parent-chain-skip default.

Fixture: a shared module counter read by BOTH the group's loader and the
parent RSC layout; the server action bumps it. After the action, in one
commit, the useLoader value shows the new count and the parent layout still
shows the pre-action count. Dev + production.
Each loader in a clientUrls() group gets its OWN revalidation decision
instead of the route-level batch: loader(Def, () => [revalidate(fn)]). The
predicate is declared in the "use client" module, so it never crosses the
projection boundary — it EXECUTES IN THE BROWSER before the partial or
action request with a client-computable subset of the server args
(currentUrl/nextUrl/currentParams/nextParams/defaultShouldRevalidate/stale/
isAction/actionId), and only its DECISION crosses the wire: an
X-Rango-Client-Reval header carrying skip/force loader ids
(client-urls/revalidation-protocol.ts).

Server side, every materialized loader stub carries a synthesized per-loader
revalidate() that honors a decision addressed to its $$id and otherwise
returns the locked default — which is also the behavior for requests that
carry no decisions (no-JS, PE, prefetch, document loads). Decisions can only
address client-urls stubs; server-tree loaders never see the header. Trust
model matches _rsc_segments: a decision only makes the client's own view
staler or fresher.

Wiring: partial-update.ts computes decisions for navigations and stale
restores; server-action-bridge.ts computes them with the action context for
the action follow-up. Locked revalidation defaults are unchanged everywhere.

Coverage: DSL validation (position/predicate/record shape), browser decision
collection (skip on action, force on stale, default-match elision, fail-open,
off-mount null), synthesized-predicate header honoring incl. malformed-header
degradation (50 units), and a dev+prod e2e where one action produces three
freshness outcomes from one counter in one commit: the group's counter loader
refreshes, its sibling with an isAction-skip predicate holds, and the
parent-chain RSC layout keeps the locked skip.

Docs: client-urls.md supported surface + revalidate section (the "no
revalidate surface" composition limit is lifted); feature-map/feature-file-map
updated.
The X-Rango-Client-Reval channel rides BOTH request kinds; the action POST
was pinned, this adds the navigation GET. The action-group gains a
/items/:itemId route; the session loader becomes revalidate(() => false)
(load-once pattern — same skip on actions as before). The action first makes
counter and session diverge, then the alpha -> beta param nav is the probe:
its GET re-evaluates the held route's loaders (params changed, default
true), so without the header the session value would catch up to the
counter. It holds — the skip transported on the navigation request. Dev +
production; gen files updated for the new named route.
@pkg-pr-new

pkg-pr-new Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@812

commit: 9588138

ivogt added 14 commits July 25, 2026 16:42
# Conflicts:
#	packages/rangojs-router/src/outlet-provider.tsx
#	packages/rangojs-router/src/segment-system.tsx
#	tests/cloudflare-basic/src/urls.tsx
…rites

Loaders can now throw the same authority signals handlers can, and write
handles through ctx.use with handler parity.

Signals: a loader that throws notFound() or redirect() no longer collapses its
segment into a 500. The notFound UI rides the loader envelope (the wrapped
promise is contracted never to reject, so a throwing boundary degrades to the
default rather than rejecting), and the document lane takes an opportunistic
real 404 status when the rejection wins the flush race. Redirect targets
resolve through the shared soft-redirect same-origin rules server-side and
commit client-side via LoaderRedirect.

Handle writes: ctx.use(Handle) inside a loader pushes to the owning segment
exactly like a handler push; ctx.get(handle) is the rendered()-gated read. The
handle store grows a second (auxiliary) settlement lane so a streaming loader
body stays legal past the handler barrier -- pushes that beat the barrier ride
the SSR snapshot, later ones stream via metadata.handlesLate (document) or the
still-open handles generator (nav/action lanes). The browser applies the late
channel only after hydration commits, since useHandle's state initializer reads
live controller state during the hydration render.

Cache: "use cache" execution runs inside a dedicated AsyncLocalStorage scope
(cache/cache-exec-scope.ts) instead of a ref-counted symbol stamp on the shared
RequestContext, which mis-tainted parallel executions.

Includes a cleanup pass over the new code:
- one notFound fallback resolver in router/error-handling.ts replacing four
  copies of the policy; the router notFound option now renders lazily instead
  of on every 404 whose nearer boundary was going to win anyway
- isDataNotFoundError hoisted to errors.ts beside isRouteNotFoundError; the two
  pre-existing bare-instanceof sites (segment-resolution/helpers.ts,
  rsc/loader-fetch.ts) now share the cross-realm-safe check
- LoaderRedirect routes through redirect-origin.ts like every other redirect
  channel, picking up the http(s) scheme check on the external branch
- shared pushHandleValue, flushWaiters and trackSettlement replace copy-pasted
  bodies in loader-resolution.ts and handle-store.ts

Test plan: typecheck clean; pnpm -r test:unit (5853) and test:unit:rsc (85) pass;
oxlint --deny-warnings and oxfmt --check clean; check:e2e-bucketing,
check:e2e-parity --strict and check:docs-api pass. New e2e client-shop-signals
and client-shop-handles cover dev and (production) via prodDescribe.
…ntUrls()

The clientUrls() runtime adds ~4.3KB gzip to cloudflare-basic's eager client
router chunk (42443B measured), tripping the 40KB ratchet that was set ~7KB
above the 33.2KB baseline of 2026-07-10. The runtime stays well inside the
50-60KB budget the guard documents, so this raises the ratchet deliberately
rather than reshaping the feature under CI pressure.

The constant now records what the bytes buy and why the obvious fix does not
work, so the next person does not repeat the search: the runtime is pinned into
the eager chunk by the static re-export at client.tsx:262, and returning
undefined from getManualChunks does not move it (measured 42443B -> 41878B;
only client-root.tsx relocates). Both public barrels — src/client.tsx and
src/index.ts — sit in that chunk, so re-homing the export does not help either.
The real fix is a dedicated @rangojs/router/client-urls subpath whose only
importer is the consumer's "use client" definition module; that is a public API
change and is tracked separately. The comment says not to raise this again to
absorb it.

Test plan: node tools/check-bundle-guards.mjs exits 0 — "router chunk
router-VOBH-mdw.js: 42443B gzip (max 44032B)", 1589B headroom, green across all
3 guarded apps. oxlint --deny-warnings and oxfmt --check clean.
…Shop link

The `Client Shop` nav link this branch added to RootLayout collides with the
pre-existing `nav a:has-text("Shop")` selectors — `:has-text()` is a substring
match, so those locators resolve to two elements and Playwright fails the click
with a strict mode violation:

  strict mode violation: locator('nav a:has-text("Shop")') resolved to 2 elements:
    1) <a href="/shop">Shop</a>
    2) <a href="/client-shop">Client Shop</a>

Reproduced 2/2 on this branch (4 failed in the vite-rsc-demo dev suite) against
2/2 clean on main at 09550a7, same machine back to back, so this was the branch
breaking existing tests rather than a flake. Switching the 8 affected locators to
`:text-is("Shop")` matches the intended link exactly and removes the latent
fragility — any future nav entry ending in "Shop" would have re-broken them.

Untouched: shop-breadcrumbs.test.ts's `text=Shop` locators are scoped to
breadcrumbNav, not the main nav, so they never collided and stayed green.

Test plan: vite-rsc-demo dev suite 147 passed / 0 failed (was 143 passed /
4 failed). Remaining 1 flaky (client-shop-signals.test.ts:36, passes on retry)
is tracked separately.
…c 404 status

client-shop-signals' "missing product responds 404" test asserted toBe(404) on
an UNWARMED document request. The real-404 status is opportunistic by design —
it lands only when the loader's notFound() rejection beats construction of the
document Response — so a first request that also pays cold work (dev module
transform) can lose that race. The 404 UI streams correctly regardless, because
it rides the envelope; only the status slips to 200.

Captured failure from the full dev suite:

  Expected: 404
  Received: 200
  > 45 | expect(response!.status()).toBe(404);

The UI and URL assertions never failed — the test passed 3/3 in isolation and
flaked only under full-suite load, which is what "opportunistic" means in
practice.

Warming the route with a plain request first makes the assertion pin the
contract we actually mean (a WARM document load of a missing product carries a
real 404) instead of startup timing. The warmup is cheap: notFound() throws
before the loader's 2s cached fetch. Applied to the production twin for the same
reason — no module transform there, but a cold worker can lose the same race.

Test plan: vite-rsc-demo dev suite 149 passed / 0 failed / 0 flaky (was 148
passed + 1 flaky); the target test 6/7 isolated runs clean with no recurrence of
the status failure. oxfmt, oxlint --deny-warnings, check:e2e-bucketing all pass.
…oaders before first flush

Document renders await flagged loaders before segment resolution returns, so
their data is settled at first paint (no fallback SSRs), their handle pushes
beat the barrier snapshot (SSR'd meta/breadcrumbs), and a thrown notFound()
deterministically precedes Response construction (real 404, no warm-up race).
Client navigations stream unchanged; the await is scoped per loader, so
unflagged siblings keep streaming.

- DSL: loader(Def, options?, use?) with options-or-use disambiguation on both
  RouteHelpers and PathHelpers; LoaderOptions exported from the root.
- Stamping: awaitBeforeFlush resolved at DSL-evaluation time from ctx.isSSR
  (entries are cached per-isSSR), declared on LoaderEntry.
- Renderer: resolveLoaders awaits flagged wrapped promises on the streaming
  path; skipped during shell capture (masked loaders would hang).
- Guard: rendered() fails fast on the flagged-loader barrier cycle
  (_awaitBeforeFlushLoaderIds), instead of hanging the document render.
- clientUrls(): same options on its loader(); stream carried on the loader
  record, serialized as projection awaitedLoaderIndices, passed through
  materialization; rejected on intercept loaders (nav-lane only).

Tests: DSL disambiguation/stamping units, resolveLoaders await units (proven
red without the renderer change), rendered() guard units, clientUrls record/
projection/materialization units, and vite-rsc-demo e2e (dev + production,
proven red without the flag): SSR'd data + Meta echo + deterministic 404 +
per-loader scoping + nav-lane streaming.
…he new loader API

New /client-urls skill: projection model, helper surface, browser-run
revalidate predicates, loader authority signals, handle writes,
stream:"navigation", intercepts, and the dashboard/admin/settings
fast-transition positioning. Registered in the catalog and the /rango index.

Corrections across 24 existing skill files, source-verified:
- Handle API rename: ctx.use(handle) is the WRITE (push fn), ctx.get(handle)
  the rendered()-gated READ — fixed in /shell-manifest (incl. a code sample
  using the wrong API), testing/handles, testing/loader, testing/middleware.
- Loader writes documented where handles are taught: /loader (new Authority /
  Writing Handles / stream:"navigation" sections + ctx table), /breadcrumbs,
  /scripts (incl. the inline-script freeze x late-loader-push interaction),
  /parallel (@meta slot marked superseded for loader-derived metadata),
  /prerender (frozen-handles claim scoped to build-time pushes), /ppr (flag
  is inert under capture), hooks/handle-and-actions, hooks/data (implicitly
  suspending useLoader), comparison reference.
- Writer list corrected everywhere: handlers and loaders push; middleware has
  no handle APIs. /theme's loader ctx.theme example replaced (loader ctx has
  no theme accessors — matches testing/loader).
- /route no longer recommends a loader for an HTTP redirect (loader redirect
  is always a client-side navigate); /router-setup documents the
  opportunistic vs deterministic loader-404 status.
- Migrations: generateMetadata / RR meta({data}) / RR loader authority map to
  loader pushes + stream:"navigation"; RR guide gains the clientUrls()
  target shape (1:1 route-module mapping, browser-run shouldRevalidate
  semantics); both guides point dashboard/admin/settings surfaces at
  /client-urls; Next.js "instant navigations" mapped to Rango prefetch.
- Repo-internal app/test paths labeled as not shipped in the package
  (/client-urls, /scripts, /react-compiler, /ppr); testing envelope docs
  gain handlePushes.
…d surface

The clientUrls() slice is feature-complete, so the Phase 1 planning language
no longer describes reality — and the docs' "not shipped" lists still named
client-run revalidate(), restricted intercept(), data-only transition(), and
loader handle writes, all since shipped.

- docs/client-urls.md (public contract): adds the loader body contract
  (signals, handle writes, rendered() reads), the
  loader(Def, { stream: "navigation" }) delivery option with its
  constraints, the deliberate-boundaries-exclusion rationale, and
  implicitly-suspending useLoader (no longer a future exploration).
- design/client-urls-instant-navigation.md: status boundary table updated to
  shipped statuses; "Client Loader Revalidation" and "Loader-Produced
  Segment Handles" re-marked since-shipped (the latter shipped via the
  general loader contract — the { data, handles } resource shape stays
  rejected); intercepts split out of the parallel section; open-questions
  list pruned to what is still open.
- design/client-urls-implementation-plan.md: phase headings dropped,
  supported-surface table extended with the shipped helpers, deferred lists
  reduced to route-local middleware / route-data transport / parallels /
  cache / boundaries (deliberate) / PPR / nested mounts.
- docs/README.md + why-rango.md: descriptions updated to the same statuses
  (why-rango no longer claims revalidation/composition are omitted).
- client-urls tests: Phase 1 naming dropped from a test title and comment.
- skills/client-urls: note that boundaries are a deliberate exclusion and
  what to use instead.
Client route groups are a performance surface — instant optimistic
presentation, held data, streaming reads — not full-feature routing. The
unsupported helpers (middleware/include/parallel/cache/boundaries/PPR) are a
design position, not a roadmap: the server tree around the include() mount is
the full-feature router, and caching reaches group data through "use cache"
in loader bodies. Reframed in docs/client-urls.md, both design docs
(Non-goals / Other Deferred Work), and the /client-urls skill.
…up navigations

The layout -> middleware -> include(clientUrls) shape needed its schedules
stated concretely: middleware wraps EVERY canonical request (every
within-group navigation round-trips, held-loader tab switches included, so
guards re-authorize each nav and ctx.set() stays fresh), while the outer
layout handler does NOT re-run (partial rendering holds its segment;
action:parent-chain-skip). Only the optimistic branch precedes middleware,
and it is presentation, never authorization. Semantics table in
docs/client-urls.md, compact version in the /client-urls skill.
…the clientUrls mount

The docs' layout -> middleware -> include(clientUrls) semantics table had no
e2e pin. The /client-shop mount is now wrapped in exactly that shape
(client-shop-guard.tsx: a monotonic x-client-shop-mw header stamped by the
middleware on every canonical request, a run-count DOM stamp rendered by the
outer layout), and client-shop-guards.test.ts asserts — dev + production —
that the header advances on a within-group navigation AND on a held-loader
tab switch (the round-trip happens even when every loader holds), while the
layout stamp is unchanged (partial rendering holds the segment). Sibling
client-shop suites unaffected (18/18); no gen changes (no new routes).
docs/client-urls.md and the /client-urls skill now cite the pin.
A navigation that mounts no new segments (isSameStructureNav: every
reconciled segment id already on screen, mode navigate) commits in a bare
startTransition — the action treatment. Search-only navs (filters, tabs)
reconcile, so React holds the visible content while the re-run loaders
stream instead of re-suspending the revealed boundary into its fallback
for the loader's full duration. Param navs still remount via their
param-bearing segment keys (transition() remains that opt-in);
cross-route navs still commit urgently. leave-intercept stays urgent.

ClientUrlsRoot now reports useOutlet().pending for ANY in-flight group
intent, same-route included — the urgent setIntent flips it at nav start
and the transition-wrapped clear() entangles with the held commit, so
pending drops exactly when the data lands.

Red-proven: unit commit-lane trio + same-route pending pin; e2e
client-shop-filters (vite-rsc-demo, rAF recorder, dev+prod) and the new
plain-product ?tab hold in same-route-nav (test-app fixture link, no
route changes). Demo grew ?category= filters on /client-shop with a
search-sensitive revalidate predicate. Docs: client-urls.md same-route
section + pending scope + transition() delta, feature-map commit-lane
entries, client-urls skill pitfall.
@ivogt
ivogt marked this pull request as ready for review July 26, 2026 20:08
@ivogt
ivogt merged commit 5015202 into main Jul 26, 2026
@ivogt
ivogt deleted the feat/client-urls branch July 26, 2026 20:08
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