Repository navigation
feat(router): clientUrls() — client route groups with instant navigation, server authority - #812
Merged
Merged
Conversation
…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.
commit: |
# 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.
7 tasks done
5 tasks done
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:After — the group lives in one
"use client"module; the browser presents instantly, the server still decides:End-to-end consumer example
See
packages/rangojs-router/docs/client-urls.mdfor the full copy-pasteable walkthrough (loader module, client module, mount, router). The short form:What shipped (consumer surface)
clientUrls()from@rangojs/router/client—path,layout,loader,loading, plusintercept(module-local, dot-target, named routes only),transition(data-only config:enter/exit/update/share/default/name/viewTransition; nowhen), andrevalidate(client-run per-loader predicates).include,parallel,cache,error(Boundary),notFound(Boundary), andmiddlewareare rejected with clear errors.include()in the canonicalurls()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.loading()renders immediately on a local match; wrapping client layouts seeuseOutlet().pending. Presentation declines when an intercept claims the target (the modal commits over the origin instead)."use client"module; the router materializes ordinaryurls()entries with loader stubs that execute registeredcreateLoader()definitions by generated id. Hard requests, SSR, and hydration use the same projected server routes.loader(Def, () => [revalidate(fn)])predicates execute in the browser on both navigations and action POSTs; only decisions cross, via theX-Rango-Client-Revalheader ({skip, force}by loader id). Requests that cannot carry decisions (no-JS, PE forms, prefetch, document loads) get the locked defaults.Semantics
loading()pending: true; canonical response commitsloading()pending: true; canonical response commitsrevalidate()decisions ride the POSTstale: trueflows into predicates; defaults unchangedrevalidate()predicate throwsCode-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-whenfor intercepts, per-loader decisionrevalidate()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().pendingchannel.getIsolatedLazyParent), andfindInterceptForRoutewalks the target chain origin-blind, so materialization synthesizes the origin scope the DSL deliberately does not expose.Test plan
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/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.e2e/client-urls-hmr.test.ts, CI-skipped watcher category, verified locally).pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format— green on every pushed commit.renderRoutecoverage forclientUrls()definitions — open follow-up (needs arenderRouteextension to accept a definition; tracked as the remaining DoD item for this feature).Notes
docs/client-urls.md).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.useLoader(spiked green end-to-end; four integration points identified — SSR fallback emission, error-boundary routing, meta-handle collection, PPR live holes).