Skip to content

feat(router): revalidate(true) and revalidate(false) shorthand in urls() and clientUrls() - #1130

Merged
ivogt merged 1 commit into
mainfrom
feat/revalidate-boolean-shorthand
Oct 13, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/revalidate-boolean-shorthand

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The two most common revalidate() rules are constants: "never re-run this once the page has it" (a session or static loader) and "re-run this on every request" (a ticker, a badge). Both had to be written as a predicate, revalidate(() => false) / revalidate(() => true), in urls() and in a clientUrls() group alike. A consumer who wrote the obvious revalidate(false) got a type error, and in plain JavaScript the router called false as a function on every request, logged the TypeError, and silently fell back to the default decision. In a clientUrls() module the build rejected a literal outright (revalidate() expects a predicate function).

Before / after

Before:

loader(SessionLoader, () => [revalidate(() => false)]),
loader(TickerLoader, () => [revalidate(() => true)]),

After:

loader(SessionLoader, () => [revalidate(false)]),
loader(TickerLoader, () => [revalidate(true)]),

Same decisions as the function form: true is a hard re-run on every navigation and action, false a hard hold once the client has the segment (a segment it doesn't hold yet still renders, and a document request still renders everything).

End-to-end consumer example

// src/account/urls.tsx (server)
import { urls } from "@rangojs/router";
import { AccountLayout, OrdersPage, OrderPage } from "./pages";
import { SessionLoader, UnreadBadgeLoader, OrdersLoader } from "./loaders";

export const accountUrls = urls(({ layout, path, loader, revalidate }) => [
  layout(AccountLayout, () => [
    // Read once; a profile edit refreshes it explicitly with useRefreshLoaders.
    loader(SessionLoader, () => [revalidate(false)]),
    // Cheap and always current: re-run on every navigation and action.
    loader(UnreadBadgeLoader, () => [revalidate(true)]),
    path("/account/orders", OrdersPage, { name: "account.orders" }, () => [
      loader(OrdersLoader),
    ]),
    path("/account/orders/:id", OrderPage, { name: "account.order" }),
  ]),
]);
// src/shop/shop.client-urls.tsx
"use client";
import { clientUrls } from "@rangojs/router/client";
import { HOLD_RECOMMENDATIONS } from "./flags"; // export const HOLD_RECOMMENDATIONS = true
import { CartLoader, RecommendationsLoader, StockLoader } from "./loaders";
import { ProductPage } from "./ProductPage";

export default clientUrls(({ path, loader, revalidate }) => [
  path("/:slug", ProductPage, { name: "product" }, () => [
    loader(StockLoader, () => [revalidate(true)]), // literal: stays in place
    loader(CartLoader, () => [revalidate(false)]),
    // Non-literal: hoisted to the server like a predicate and evaluated there.
    loader(RecommendationsLoader, () => [revalidate(HOLD_RECOMMENDATIONS)]),
  ]),
]);

Semantics

Argument urls() (route, layout, parallel, loader, intercept loader) clientUrls() route or intercept loader, built (rango plugin) clientUrls() loader, Vitest (no plugin)
true constant hard true literal stays in place; projection carries true; materialization attaches the constant predicate stored as true; projection carries true
false constant hard false same, false same, false
a predicate unchanged unchanged (hoisted, keyed, registered) unchanged
a non-literal boolean (revalidate(FLAG)) constant, as true/false hoisted like a predicate; the server evaluates it; the registry stores the constant predicate stored as the boolean
a binding of the same "use client" module n/a build error naming file:line:column (unchanged rule) n/a
a non-literal that evaluates to anything else throws when urls() is built left unregistered; fails at materialization (group mount) with the missing-key error, naming loader, route or intercept, and module throws in clientUrls() (dev/test)
undefined, null, a number, string, object throws when urls() is built (was: per-request TypeError, logged, deferred) literal: build error at the call site throws in clientUrls() (dev/test)
"defer to the default" still the function form (() => undefined) same same

Left unchanged deliberately: there is no shorthand for a defer or for a soft { defaultShouldRevalidate } verdict.

Intercept loaders: since #1132, a clientUrls() intercept's loaders project and materialize through the same helpers as a route's (serializeLoaderOptions, loaderRevalidators, materializeLoaders in server-projection.ts), so the booleans reach them with no extra wiring. Pinned by the projection unit "projects an intercept loader's revalidate(true | false) and runs it on the server" (projection.intercepts![0]!.loaderRevalidate is [[true], [false, "k-modal-flag"]], and the materialized intercept loaders answer true on a same-URL navigation and false on an action).

Why a non-literal is hoisted rather than rejected

The build sees revalidate(FLAG) and revalidate(isCartAction) as the same shape: an imported identifier. It cannot tell a boolean import from a predicate import without evaluating the module, so a "non-literal boolean" build error could only catch syntactic forms (!x, a === b) and would make a program that typechecks (fn: ShouldRevalidateFn | boolean) fail to build. Hoisting it keeps one rule ("the argument runs on the server; it may reference imports and globals only"); the registry leaves a value that is neither a function nor a boolean unregistered, and materialization fails with the loader, route and module named. A literal is not hoisted: it is the same constant in every environment, and a module whose only revalidate() calls are literals gets no ?rango-revalidate module at all.

Code changes

  • src/router/revalidation.ts — toShouldRevalidateFn(fn | boolean): two shared constant predicates. Booleans are converted where they enter, so every evaluation path (segments, loaders, intercept loaders, parallels, cache lookup, prefetch: false deferral) is unchanged.

  • src/route-definition/dsl-helpers.ts — the revalidate() helper rejects a non-function, non-boolean argument and stores toShouldRevalidateFn(fn):

    invariant(
      typeof fn === "function" || typeof fn === "boolean",
      `revalidate() expects a predicate or a boolean, got ${fn === null ? "null" : typeof fn}`,
    );
    ...
    parent.revalidate.push(toShouldRevalidateFn(fn));
  • Types: RouteHelpers["revalidate"], PathHelpers["revalidate"], ClientUrlHelpers["revalidate"] take ShouldRevalidateFn | boolean; ClientUrlLoaderRecord.revalidate and both projections' loaderRevalidate (ClientUrlProjectionRoute, ClientUrlProjectionIntercept) carry booleans; registerClientUrlRevalidators accepts a boolean value; runRevalidate takes booleans alone or in a chain.

  • src/vite/plugins/client-urls-revalidate-hoist.ts — a boolean Literal is skipped (left in place); other literals fail with "expects a predicate function or a boolean"; no server module when nothing was hoisted.

  • src/client-urls/client-urls.ts — accepts a boolean. The argument check is now dev-only (the prefetch option check's pattern): in a build the plugin checks a literal at its call site and the server checks any other argument at group mount. In the production cloudflare-basic client chunk the helper only builds the item, and the error string is absent (grep -c 'predicate function' router-*.js is 0).

  • src/client-urls/server-projection.ts — in the shared route-and-intercept helpers: serializeLoaderOptions projects a boolean as itself, and loaderRevalidators converts at the source, so its return type stays ShouldRevalidateFn[] and materializeLoaders is unchanged:

    return (projected.loaderRevalidate?.[loaderIndex] ?? []).map((entry) =>
      toShouldRevalidateFn(
        typeof entry === "boolean" ? entry : fromRegistry(entry),
      ),
    );

    The missing-key error now also names an argument that evaluated to neither a function nor a boolean.

  • src/client-urls/revalidate-registry.ts — converts at registration, so the map holds only functions and the per-call lookup stays a plain call (a dev re-evaluation that flips a hoisted flag still applies on the next request):

    for (const [key, fn] of Object.entries(fns)) {
      if (typeof fn === "function" || typeof fn === "boolean") {
        revalidators.set(key, toShouldRevalidateFn(fn));
      } else {
        revalidators.delete(key);
      }
    }
  • src/testing/internal/client-urls-projection.ts — a boolean rides the test projection as itself, as the plugin leaves a literal.

Test plan

  • Internal unit: src/route-definition/__tests__/revalidate-shorthand.test.ts (DSL stores the constant predicate on segments and loader entries, in order; rejects undefined/null/string/object (revalidate() expects a predicate or a boolean, got <type>); runRevalidate takes booleans and chains); src/vite/__tests__/client-urls-revalidate-hoist.test.ts (literal left in place beside a hoisted predicate, literal-only module untouched and its RSC proxy without the import, a predicate edited into a literal drops the import, a non-literal HOLD, !import.meta.env.DEV, HOLD ? true : false and !HOLD hoisted, a module-local const HOLD = false rejected naming file:line, number/null/template literals rejected); src/client-urls/__tests__/client-urls.test.ts (booleans stored in order for route and intercept loaders; bad arguments throw outside production, skipped in production); src/client-urls/__tests__/server-projection.test.tsx (route projection carries booleans and survives JSON; materialized constants answer per the default table; new "projects an intercept loader's revalidate(true | false) and runs it on the server" pins projection.intercepts![0]!.loaderRevalidate = [[true], [false, "k-modal-flag"]] and the materialized intercept loaders' answers; a hoisted expression registered as a boolean runs and flips per call, and a string value is left unregistered and fails at materialization with the missing-key error; definition-object path).
  • Userland (public testing primitives, serveShellRequest): src/testing/__tests__/client-urls-revalidate.rsc-test.tsx "revalidate(true) and revalidate(false) on a clientUrls() group loader / a urls() route loader" — pins, side by side, that on a same-URL partial navigation revalidate(true) re-runs while the default and revalidate(false) hold, on an action revalidate(true) and the default re-run while revalidate(false) holds, and a document renders all three. Red before green: with the DSL conversion reverted, 4 of these fail (expected { always: 0, never: 0, plain: 0 } to deeply equal { always: 1, never: 0, plain: 0 }; expected { always: 1, never: 1, plain: 1 } to deeply equal { always: 1, never: 0, plain: 1 }).
  • Consumer dogfood: tests/cloudflare-basic/test/client-urls-revalidate-boolean.rsc-test.tsx — the app's own group through @rangojs/router/testing/flight; red with the conversion reverted.
  • E2e, dev + (production), on base e7b3d88 (the one main commit since, chore(router): regenerate per-module route type files and gate them in CI #1127, changes only route-type generation and gen files; check:route-gen passes on the rebased commit). Router test-app 115/115 (57 dev + 58 production: client-urls, client-urls-loader-element, client-urls-slow, intercept-held, parallel-new-slot-reval, history-cache-handles); cloudflare-basic 70/70 (35 + 35: the same plus client-urls-ssr-signals, client-urls-vars):
    • router test-app e2e/client-urls.test.ts: new "a same-URL navigation re-runs the revalidate(true) group loader; the default and revalidate(false) hold"; the session loader fixture now uses revalidate(false) (action hold, param-nav hold, prefetched param-nav hold, no-JS render).
    • cloudflare-basic e2e/client-urls.test.ts: new "revalidate(true) re-runs a group loader where the default holds; revalidate(false) holds where the default re-runs" (an action, then a same-URL navigation).
    • Server routes in both apps, fixtures moved to the shorthand: history-handles (revalidate(true) re-runs B's loader on a history-cache navigation where the route segment holds), intercept-held (revalidate(false) holds an intercept loader across an action, beside a default control), parallel-new-slot-reval (revalidate(false) on a route-scoped parallel slot).
  • HMR (dev-only, allowlisted): e2e/client-urls-hmr-revalidate.test.ts new "editing the predicate into revalidate(false) or revalidate(true) changes the next action's decision without a restart" (a literal is not hoisted, so the refreshed projection carries the boolean; back to the predicate restores the hoisted path). hmr-client-urls project 4/4 in each of 3 runs with --retries=0 (12/12); test:hmr-routes-local 21/21.
    • The suite's shared decide() helper now bounds every wait (goto, hydration, reads, click) and returns "unsettled" on any failure, so the poll retries. Why: right after an edit's RSC "program reload", the dev per-request trie rebuild can throw include() could not resolve the server projection ... or ... has no server registration (key ...) for a moment. That race is on main too (seen in a main run of the existing test, which passed through the poll retry). Before this change the unbounded textContent() hung on the error page or goto aborted on an HMR reload, and the test failed: the existing test failed 3 of 10 runs on this branch, 0 of 8 on main. With the bounded helper, 3 full-project runs passed while the dev server logged 3 to 6 of those transient errors per run. The race itself is not fixed here.
  • pnpm run typecheck, pnpm run test:unit:all (router 8751 passed, router rsc 846 passed, cloudflare-basic 85 + 29 rsc, mini 25 + 3, vite-rsc-demo 27 + 1, shared-e2e 20), pnpm run lint, pnpm run format, check:e2e-bucketing, check-e2e-parity --strict, check:docs-api, check:skills, check:skills-catalog, check:doc-links, check:no-external-store, check:route-gen (201/201 current).
  • check-bundle-guards green across 3 apps: cloudflare-basic router chunk 49,346 B gzip (limit 49,536 B; main about 49,394 B). The dev-only check is absent from the production client chunk (grep -c 'predicate function' router-*.js is 0).

Notes

  • Docs: docs/client-urls.md (now also says a production build checks a literal at its call site and any other argument at group mount; the loader skill says the same in one line), skills loader (return shapes), layout, client-urls, intercept, parallel, server-actions; docs site features/caching.mdx (return shapes), guides/client-urls.mdx, layouts.mdx, intercepting-routes.mdx, parallel-routes.mdx, server-actions.mdx; internal feature-map.md, feature-file-map.md. CHANGELOG ## Unreleased.
  • Behavior change for malformed input only: revalidate(undefined) (and any non-function, non-boolean) in urls() now throws at definition time. Before, it was called per request, logged a TypeError and deferred. TypeScript already rejected it.
  • tests/vite-rsc-demo fixtures keep the function form; they still work and their suites were not run here.
  • Follow-up, not in this PR: the dev race behind the HMR hardening above. Right after an edit's RSC program reload, a request's per-request trie rebuild can run before the projections and hoisted registrations are back, and it throws include() could not resolve the server projection or has no server registration. It recovers on the next request. It is on main as well.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ad9c57f

@ivogt
ivogt force-pushed the feat/revalidate-boolean-shorthand branch from ad3a7a5 to 683e50f Compare October 13, 2026 03:32
@ivogt
ivogt marked this pull request as ready for review October 13, 2026 03:32
…s() and clientUrls()

revalidate() takes a boolean wherever it is accepted: true is
revalidate(() => true), false is revalidate(() => false). Undefined (defer)
still needs the function form.

- urls(): the helper stores the constant predicate (toShouldRevalidateFn in
  router/revalidation.ts), so every evaluation path is unchanged; any other
  non-function argument now throws at definition time.
- clientUrls(): the hoist plugin leaves a boolean literal in place and hoists
  any other argument as before (an imported boolean is evaluated on the
  server). The shared route-and-intercept projection carries the boolean, and
  materialization turns it into the constant predicate, so intercept loaders
  take it too. The registry stores a hoisted boolean as its constant
  predicate and leaves any other value unregistered, which fails at
  materialization. The browser-side argument check is dev-only.
- runRevalidate takes booleans alone or in a chain.

Tests: DSL, hoist, clientUrls runtime and projection units (route and
intercept loaders); serveShellRequest userland for a group loader and a
server route (same-URL navigation and action); cloudflare-basic dogfood;
dev + production e2e in both apps; HMR predicate-to-literal edit. The
clientUrls revalidate HMR suite's decide() bounds every wait and reports
"unsettled" on a failed call, so its poll retries through the dev server's
transient re-discovery errors after a program reload.
@ivogt
ivogt force-pushed the feat/revalidate-boolean-shorthand branch from 683e50f to ad9c57f Compare October 13, 2026 03:41
@ivogt
ivogt merged commit 6458728 into main Oct 13, 2026
59 checks passed
@ivogt
ivogt deleted the feat/revalidate-boolean-shorthand branch October 13, 2026 03:56
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
…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 ivogt mentioned this pull request Oct 14, 2026
1 task done
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