Skip to content

fix(router): a clientUrls() layout() loader is held across its routes and follows its revalidate(), as a server layout's does - #1137

Merged
ivogt merged 1 commit into
mainfrom
fix/client-urls-layout-loader-revalidate
Oct 13, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/client-urls-layout-loader-revalidate

Conversation

@ivogt

@ivogt ivogt commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: dc-t38 · session dc-s46

Summary

In a clientUrls() group, a loader declared on a layout() ran again on every navigation to another route under that layout, whatever its revalidate() said, even () => false. Now a group layout() loader revalidates as a server layout's loader does: a navigation keeps it by default (cross-route, param and search changes alike), an action re-runs it, and its revalidate() decides on every navigation and action with that default. A loader it holds keeps its data and its handle pushes, the layout stays mounted with no fallback, and the canonical commit no longer waits for the loader. No wire, cache or handle-record format changes.

Problem

The vite-rsc-demo shop declares its cart loader on the shop layout() with revalidate(({ isAction }) => isAction(ShopActions)), commented "skip on every navigation". It ran on every click between the product list and a product page: a wasted server run per click, and since 0.24.2 (G1) the optimistic frame kept the old cart but the canonical commit still waited for the re-run. A revalidate(() => false) layout loader ran 0 times on a param navigation and 1 time on a cross-route navigation (serveShellRequest, research dc-t32, doc dc-d24 section 2).

The cause: applyConfig (client-urls.ts) copies a layout's loaders into every route record below it, and a loader segment's id names its route (${routeShortCode}D${index}.${$$id}, segment-resolution/revalidation.ts). On a cross-route navigation the destination's copy was a segment the client lacked, so the server ran it as new-segment and never asked its revalidate().

Before / after

export default clientUrls(({ layout, path, loader, revalidate }) => [
  layout(ShopLayout, () => [
    loader(CartLoader, () => [
      revalidate(({ isAction }) => isAction(CartActions)),
    ]),
    path("/", ShopIndex, { name: "index" }),
    path("/product/:slug", ProductPage, { name: "product" }),
  ]),
]);
Navigation inside the group Before After
/product/a -> /product/b (param) predicate decides: held predicate decides: held (unchanged)
/product/a -> /product/b, no revalidate() re-runs (a route's default) held (a layout's default)
/ -> /product/a (cross-route) CartLoader runs, commit waits predicate decides: held, commit does not wait
/product/a -> / (cross-route) CartLoader runs, commit waits predicate decides: held, commit does not wait
cart action on any page runs runs (unchanged)
into the layout from outside the group runs runs (unchanged)
into the layout from a group route outside it runs runs (unchanged)

End-to-end consumer example

"use client";
import { clientUrls, useLoader, useHandle, useOutlet } from "@rangojs/router/client";
import { CartLoader, Crumbs } from "./shop.loaders";
import * as CartActions from "./cart.actions";

function ShopLayout() {
  const { content, pending } = useOutlet();
  const { data: cart } = useLoader(CartLoader); // pushes a "Cart (n)" crumb
  const crumbs = useHandle(Crumbs) ?? [];
  return (
    <section aria-busy={pending}>
      <nav>{crumbs.join(" / ")}</nav>
      <CartBadge count={cart.count} />
      {content}
    </section>
  );
}

export default clientUrls(({ layout, path, loader, revalidate }) => [
  layout(ShopLayout, () => [
    loader(CartLoader, () => [
      revalidate(({ isAction }) => isAction(CartActions)),
    ]),
    path("/", ShopIndex, { name: "index" }),
    path("/product/:slug", ProductPage, { name: "product" }, () => [
      loader(ProductLoader),
    ]),
  ]),
]);

The cart loader runs on the document request and on cart actions only. Its crumb stays on screen across / -> /product/a -> /.

Semantics

Case Behavior
layout loader, revalidate() returns false (or a skip rule) held on navigations between the layout's routes; data and handle pushes kept
layout loader, revalidate() returns true runs, as before
layout loader, no revalidate() a server layout's default: held on every navigation (cross-route, param, search, Back), re-run on an action
the same loader declared on another layout() another loader: entering that layout runs it
route-own loaders unchanged
actions unchanged
wildcard prefetch adopted from another page the browser carries the current page's copy; with none it refetches with nothing held (existing path)
layout loader with prefetch: false, prefetch between the layout's routes on screen, so never deferred: its revalidation decides. With a rule that re-runs it, it runs in the prefetch with the rest of the destination (dc-q23 (a)); with no rule it is held. Before: deferred, and the click's fill ran it. A prefetch that enters the layout still defers it

Behavior change against 0.24.2 (dc-q24, user: "on the navigation from shop to item the layout should not revalidate ... wed only render the item route and keep the layout and its loader stable"). A group layout() loader with no revalidate() re-ran on every param or search change (a route loader's default, since its copy belonged to the route). It is now held, as a server layout's loader is. A layout loader that reads the route's params needs a rule:

loader(ShopNavLoader, () => [
  revalidate(({ currentParams, nextParams, defaultShouldRevalidate }) =>
    currentParams.slug !== nextParams.slug ? true : defaultShouldRevalidate,
  ),
]),

prefetch: false (dc-q23 (a), user: "if we need revalidation, ignore the flag in general as it is on screen"). The flag skips a loader in a prefetch only when it is new to the page; a loader on screen is never deferred, and runs in the prefetch when its revalidation re-runs it. Same-route navigations already worked this way; a group layout() loader now counts as on screen on every route of its layout.

Code change

Shape (b) from the ticket: the server treats the page's copy of a layout-owned loader as holding the destination route's copy, and the browser carries the held data to the destination's id. The loader-segment id grammar, the Flight payload, cache records and handle records are unchanged.

  • client-urls/server-projection.ts: the projection numbers each route's layout loader records (ClientUrlProjectionRoute.layoutLoaders, ordinals of the layoutLoaders records G1 introduced). Materialization reads every group route's shortCode up front (peekShortCode), and each route's use() records, per layout loader copy, the ids that loader has under the layout's other routes (setLayoutLoaderCopies).
  • client-urls/layout-loader-copies.ts (new): the record, keyed by the materialized loader stub, and withLayoutLoaderCopies(held, route).
  • router/match-api.ts: the partial match context's held set (clientSegmentSet, and the prefetch deferral plan's) adds the destination's copy when the page holds a sibling copy. From there the existing loader funnel asks the loader's revalidate() and leaves a held copy out of the response.
  • server/context.ts: getShortCode and the new peekShortCode share one formula (nextShortCode).
  • router/segment-resolution/revalidation.ts (resolveOwnerLoaders): a layout loader copy's revalidation input is a non-route segment (belongsToRoute && !isLayoutLoaderCopy(loader)), so evaluateRevalidation gives it a layout's default (nav:non-route-skip on a GET, action:loader-segment on a POST). The emitted segment is unchanged; the server tree's loaders never reach the check.
  • browser/segment-reconciler.ts: in the fallback for a matched id the server did not send and the page does not hold, the id is looked up with the destination group route's id replaced by the page's group route's id (same clientGroup), and carried as a new segment object with the same loaderData reference.
  • handles/pushed-by.ts: mergePartialHandleData walks the new segment order; a segment with no values of a handle takes, for each loader registered on it that did not run, the values that loader pushed under a segment the page leaves, never from a slot (.@).

Why here: the match context is the one place every partial lane (navigation, prefetch, fill, action) reads the held set from, so the existing revalidation path decides without a clientUrls-specific branch in the loader funnel. A layout-scoped id (shape a) would change the id grammar parsed in segment-system.tsx, handles/pushed-by.ts, cache/shell-snapshot.ts and router/match-api.ts.

Test plan

  • Userland (@rangojs/router/testing), src/testing/__tests__/client-urls-layout-revalidate.rsc-test.tsx through serveShellRequest, 6 cases:
    • a layout loader with revalidate(() => false) runs 0 times on /shop/items/a -> /shop, and matched names it under the destination without sending it;
    • with no revalidate() the loader is held on a cross-route, a same-route param and a search-only navigation, and re-runs on an action;
    • a rule on currentParams/nextParams decides, and a predicate sees defaultShouldRevalidate false on a param navigation and true on an action;
    • an isAction rule re-runs it on the route the navigation reached;
    • in a prefetch between the layout's routes, a prefetch: false layout loader with a re-run rule runs and one with no rule is held, nothing deferred;
    • entering the layout runs every layout loader.
    • Red: 4 of 6 fail on main and on 6c9ffd287 (before the layout default).
  • src/testing/__tests__/prefetch-false-on-screen.rsc-test.tsx pins the on-screen premise for every loader kind: a route's own loader on a same-route param navigation, a server layout() loader with a param rule (runs or held), a clientUrls() layout loader (re-run rule runs, no rule held), and an intercept() loader with the modal open. Each is also deferred when it enters fresh. Red on main for the clientUrls() case only: both copies deferred (["M0L0L0I0R0D0.…", "M0L0L0I0R0D1.…"], [] expected).
  • Handle carry from a slot: two cases in __tests__/event-controller.test.ts, red before the .@ guard (P1: ["cart:1", "cart:1", "page:index"]; P2: a kept layout took "Product 7").
  • Internal unit: client-urls/__tests__/server-projection.test.tsx (copies recorded, also from an evaluation for one route; another layout's copy holds nothing; isLayoutLoaderCopy only on layout copies), __tests__/segment-reconciler.test.ts, __tests__/event-controller.test.ts.
  • E2e, dev + production, router test-app and cloudflare-basic: "a layout() loader with no revalidate() keeps its data and crumb across route switches, a param navigation, a search change and Back, and an action re-runs it" (tests/shared-e2e/src/client-urls-layout-loader-scenario.ts). The group sits behind a 400 ms middleware and the layout loader takes 300 ms.
    • Steps: cross-route, param navigation, Back, search-only change, cross-route, then an action.
    • Checks after each navigation: run count, crumb, and layout and cart DOM identity.
    • Canonical commits: 405-437 ms after the click, against 706-725 ms before.
    • Red on 6c9ffd287 (route default): 708 ms dev and 715 ms production at the first cross-route click.
  • Router test-app, dev + production: client-urls, -loader-element, -slow, intercept-held, held-boundary, suspense-cases: 195 passed. client-urls-hmr*: 3 passed, 1 flaky (the async route edit; main fails it too). cloudflare-basic client-urls*, intercept-held, held-boundary, suspense-cases: 152 passed. vite-rsc-demo full suite, dev + production: 310 passed. G2 test x10 per mode under CPU load: 20/20 in each app.
  • turbo run typecheck --env-mode=loose, pnpm run test:unit:all (router 8785, RSC 856, every consumer suite), pnpm run lint, pnpm run format, check:e2e-bucketing, check-e2e-parity --strict, check:route-gen, check:suspense-contract, check:no-external-store, check:docs-api, check:doc-links, check:skills, check-bundle-guards after build-router (router chunk 49,518 B gzip, limit 49,536 B).

Notes

  • The handle carry recognizes the case by its shape (a loader registered on a segment with no values that did not run). A prefetch: false loader that a prefetch deferred on a new segment has the same shape: if the page it leaves had pushes from the same loader, they show until the fill replaces them.
  • Bundle: the cloudflare-basic router chunk is 49,518 B gzip (limit 49,536 B, main 49,388 B, +130 B, 18 B left). The handle carry lives in the merge loop (which now walks segmentOrder), drops a ran check the loop already applies, and skips slot sources (+6 B). The reconciler lookup is one id replacement in the cache fallback.
  • Review P3 stays: a deferred prefetch: false loader on a new route shows the previous route's push until the fill (first note above).
  • Review finding 3 (follow-up): the reconciler carry is limited neither to loader ids nor to one group when two groups both mount at /. It fires only when an adopted wildcard prefetch misses a destination child. Limiting it to loader ids (destination.id + "D") costs +6 B, so it is left for the follow-up.
  • The fixture's runs loader names its own no-op rerunClientLayoutRuns action, not the cart bump. The G1 case checks the shared cart count exactly, and a bump from this case in a parallel worker made it read cart:2 (seen on the backport run).
  • G1's two cases fail under --repeat-each on main as on this branch: concurrent copies of them bump and read one module-level cart count.
  • Backport to v0.24.2 (0.24.3): 4 conflicts, resolved as before (rerere reused two). The layout default applies to v0.24.2's resolveLoadersWithRevalidation without conflict. prefetch-false-on-screen drops its intercept() case there (intercept loaders become loader segments in feat(router): intercept() loaders are loader segments of their slot: prefetch: false, per-loader revalidate() and handles reach modals #1132). CHANGELOG.md: the G2 entry under a new ## Unreleased. docs/internal/feature-map.md: the G2 sentence without feat(router): revalidate(true) and revalidate(false) shorthand in urls() and clientUrls() #1130's. server-projection.ts: v0.24.2's loaderRevalidate type and inline route loaders, wrapped in recordingCopies, the layoutLoaders spread before transition, plus the ClientUrlLoaderRecord type import. match-api.ts: held without the intercept deferral scope. The backport's router chunk is 49,663 B against 49,532 B on v0.24.2; the limit is raised on release/0.24.x (dc-q22).

@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ba5fd95

…on navigations between the group's routes

In a clientUrls() group, a loader declared on a layout() ran on every
navigation to another route under that layout, whatever its revalidate()
said, even () => false. applyConfig copies a layout's loaders into every
route record below it, and a loader segment's id names its route
(`${routeShortCode}D${index}.${$$id}`), so the destination's copy was a
segment the client lacked and the server ran it as new-segment. The
canonical commit waited for the run (G1 already kept the optimistic frame).
With no revalidate() the copy also took a route's default and re-ran on every
param or search change of one route.

The server now treats the page's copy as holding the destination's. The
projection numbers each route's layout loader records (layoutLoaders), and
materialization records for each copy the ids the same loader has under the
layout's other routes (client-urls/layout-loader-copies.ts). It reads every
group route's shortCode with peekShortCode (server/context.ts, which now
shares one formula with getShortCode), including the routes an evaluation for
another route skips. createMatchContextForPartial adds the destination's copy
to the held set (and the prefetch deferral plan's) when the page holds a
sibling copy, so the existing loader funnel asks the loader's revalidate() and
leaves a held copy out of the response while matched names it.

A copy now revalidates as a server layout's loader does: resolveOwnerLoaders
gives its decision a non-route segment (isLayoutLoaderCopy), so a navigation
holds it (nav:non-route-skip, params and search changes included, a
same-route param navigation too) and an action re-runs it
(action:loader-segment). Its revalidate() decides with that default as
defaultShouldRevalidate. A layout loader that reads the route's params now
needs a rule, as on a server layout. Entering the layout, from outside the
group or from a group route outside the layout, runs it.

The browser carries the held copy: reconcileSegments looks a matched id the
server did not send up under the page's group route (the destination route's
id replaced by the origin's) and keeps its loaderData reference, and
mergePartialHandleData gives a segment with no values of a handle the values a
loader registered on it, that did not run, pushed under a segment the page
leaves, never from a slot (an intercept's or a parallel's own copy). No wire,
cache or handle-record format changes; the loader segment id grammar is
unchanged.

prefetch: false skips a loader in a prefetch only when it is new to the page;
a loader on screen is never deferred. A group layout() loader is now on
screen on every route of its layout, so a prefetch between those routes runs
it when its revalidate() re-runs it and holds it otherwise. Docs state the
rule once for every loader kind.

Tests:
- userland serveShellRequest suites: client-urls-layout-revalidate (held on
  cross-route, param and search navigations, re-run on an action, the default
  a rule sees, the flagged copy in a prefetch; 4 of 6 cases fail on main) and
  prefetch-false-on-screen (one case per loader kind; the clientUrls case
  fails on main, where both copies are deferred);
- unit cases in server-projection, segment-reconciler and event-controller
  (the slot cases fail without the slot guard);
- a dev + production e2e in the router test-app and cloudflare-basic (shared
  scenario): a 300 ms layout loader with no revalidate() keeps its value, crumb
  and DOM across two cross-route navigations, a param navigation, Back and a
  search change, both canonical commits land under 700 ms after the click
  (405-437 ms; 706-725 ms before), and an action re-runs it. That action is not
  the fixture's cart bump, whose count the G1 case checks exactly.

The cloudflare-basic router chunk is 49,518 B gzip (limit 49,536 B, main
49,388 B).
@ivogt
ivogt force-pushed the fix/client-urls-layout-loader-revalidate branch from 9376744 to ba5fd95 Compare October 13, 2026 06:36
@ivogt ivogt changed the title fix(router): a clientUrls() layout() loader follows its revalidate() on navigations between the group's routes fix(router): a clientUrls() layout() loader is held across its routes and follows its revalidate(), as a server layout's does Oct 13, 2026
@ivogt
ivogt marked this pull request as ready for review October 13, 2026 06:36
@ivogt
ivogt merged commit 2abd252 into main Oct 13, 2026
59 of 78 checks passed
@ivogt
ivogt deleted the fix/client-urls-layout-loader-revalidate branch October 13, 2026 06:51
ivogt added a commit that referenced this pull request Oct 13, 2026
… on navigation; revalidate(true|false) shorthand

A group layout() loader now takes a server layout's revalidation default
(#1137): held on navigation, re-run after actions unless its revalidate()
narrows that. The loader-defaults tables, the shop recipe and the cart rule
say so, and the known gap is gone. prefetch: false skips only a loader new
to the page. The revalidate() anti-patterns use the #1130 shorthand.
ivogt added a commit that referenced this pull request Oct 13, 2026
… in 0.24.3

A clientUrls() layout() loader is held across the group's routes since
#1137, so the e-commerce recipe no longer steers the cart loader to a
server layout for that reason, and large-app loses its known-gap section.
The nested unnamed include note now says 0.24.x: #1128 ships after 0.24.3.
ivogt added a commit that referenced this pull request Oct 13, 2026
0.24.3 was published from release/0.24.x with #1137 and #1139 only. main
takes the version and moves those two entries under their own heading; the
rest of Unreleased stays for the next release.
ivogt added a commit that referenced this pull request Oct 13, 2026
…with urls() and clientUrls() (#1134)

* docs(skills): ux-architecture — a recipe for fast, stable navigation with urls() and clientUrls()

A new skill, packages/rangojs-router/skills/ux-architecture/SKILL.md, for
agents that design or review a Rango app's routing, layout, loading and
navigation UX. The existing skills are organised by primitive; none says
where layouts, loaders, boundaries, revalidate() rules, prefetch and modals
go so a click shows the next page at once, nothing on screen remounts, and
no fallback re-flashes.

- The UX contract in one place: what Rango guarantees and what the app
  must do to keep it, and how to judge it (a build, a 300-500 ms server).
- A decision tree: server urls() pages vs clientUrls() groups, server
  structure around an include(), cross-group navigation, layout and loader
  placement with the revalidate() defaults and four predicates, loading()
  vs inline Suspense, prefetch (prefetch: false and its measured cost),
  transition(), intercept() and parallel(), actions, the three update
  channels, handles.
- Five recipes: a shop (vite-rsc-demo client-shop as the reference),
  dashboard tabs, settings forms, search with filters, a modal over a list.
- Fifteen anti-patterns, each with the symptom and the fix.
- Known gaps: dc-t38 with a measured workaround, dc-t30, React's 300 ms
  throttle and the other documented React gaps.

Wiring: the rango index row, catalog.json, a pointer in docs/README.md, and
the skill added to check-docs-api's guarded docs.

* docs(skills): ux-architecture: a clientUrls() layout() loader is held on navigation; revalidate(true|false) shorthand

A group layout() loader now takes a server layout's revalidation default
(#1137): held on navigation, re-run after actions unless its revalidate()
narrows that. The loader-defaults tables, the shop recipe and the cart rule
say so, and the known gap is gone. prefetch: false skips only a loader new
to the page. The revalidate() anti-patterns use the #1130 shorthand.

* docs(skills): ux-architecture: links by name (ctx.reverse, useReverse with the generated map) and throw rewrite()
ivogt added a commit that referenced this pull request Oct 13, 2026
…-app, content and e-commerce apps (#1135)

* docs(skills): app-structure — concrete urls() layouts for SaaS, multi-app, content and e-commerce apps

A new skill, packages/rangojs-router/skills/app-structure/, puts the
primitives together into whole apps. SKILL.md holds the rules every recipe
shares (route tree in its own module, a named sync include() per section,
identity in router.use() and gates in route middleware, shared chrome in a
server layout, generated files), sync vs async include(), include naming
(name nested includes through 0.24.2, #1104), where shared chrome lives, a
growing-an-app path, and the minimal one-module app. Four companion recipes:

- large-app.md: a server shell over auth, onboarding, app, settings and
  admin clientUrls() groups; session in global middleware, sign-in,
  onboarding and role guards around the includes; prefetch: false with its
  measured trade-off; actions + revalidate(), refetch()/refreshGroup and
  cache tags for updates; fetchable loaders guard themselves; the group
  layout() loader gap through 0.24.2 and its workaround.
- multi-app.md: createHostRouter() with apex, app., admin. and docs. apps,
  what is shared and how, one TypeScript program per app, the host-override
  cookie in dev, cross-app links as document navigations.
- content-site.md: docs with Prerender + ppr and a Static nav, a blog with
  Prerender onDemand + Passthrough, the publish webhook beside the entry,
  machine routes.
- e-commerce.md: ppr category and product pages with tagged "use cache"
  reads and live loader holes, the cart as a layout loader, a guarded
  server-rendered checkout, the clientUrls() shop variant.

Wiring: the rango index and README tables list the skill, catalog.json is
regenerated, check-docs-api guards the five files, and check-skills
allowlists the /app and /settings route examples.

* docs(skills): app-structure: drop the group layout() loader gap fixed in 0.24.3

A clientUrls() layout() loader is held across the group's routes since
#1137, so the e-commerce recipe no longer steers the cart loader to a
server layout for that reason, and large-app loses its known-gap section.
The nested unnamed include note now says 0.24.x: #1128 ships after 0.24.3.

* docs(skills): app-structure: links by name (ctx.reverse, useReverse with the generated map) and throw rewrite()
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