Repository navigation
feat(router): revalidate(true) and revalidate(false) shorthand in urls() and clientUrls() - #1130
Merged
Merged
Conversation
commit: |
7 of 9 tasks
ivogt
force-pushed
the
feat/revalidate-boolean-shorthand
branch
from
October 13, 2026 03:32
ad3a7a5 to
683e50f
Compare
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
force-pushed
the
feat/revalidate-boolean-shorthand
branch
from
October 13, 2026 03:41
683e50f to
ad9c57f
Compare
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.
8 of 9 tasks
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()
1 task 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
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), inurls()and in aclientUrls()group alike. A consumer who wrote the obviousrevalidate(false)got a type error, and in plain JavaScript the router calledfalseas a function on every request, logged theTypeError, and silently fell back to the default decision. In aclientUrls()module the build rejected a literal outright (revalidate() expects a predicate function).Before / after
Before:
After:
Same decisions as the function form:
trueis a hard re-run on every navigation and action,falsea 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
Semantics
urls()(route, layout, parallel, loader, intercept loader)clientUrls()route or intercept loader, built (rango plugin)clientUrls()loader, Vitest (no plugin)truetruetrue; materialization attaches the constant predicatetrue; projection carriestruefalsefalsefalsefalserevalidate(FLAG))true/false"use client"modulefile:line:column(unchanged rule)urls()is builtclientUrls()(dev/test)undefined,null, a number, string, objecturls()is built (was: per-requestTypeError, logged, deferred)clientUrls()(dev/test)() => undefined)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,materializeLoadersinserver-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]!.loaderRevalidateis[[true], [false, "k-modal-flag"]], and the materialized intercept loaders answertrueon a same-URL navigation andfalseon an action).Why a non-literal is hoisted rather than rejected
The build sees
revalidate(FLAG)andrevalidate(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 onlyrevalidate()calls are literals gets no?rango-revalidatemodule 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: falsedeferral) is unchanged.src/route-definition/dsl-helpers.ts— therevalidate()helper rejects a non-function, non-boolean argument and storestoShouldRevalidateFn(fn):Types:
RouteHelpers["revalidate"],PathHelpers["revalidate"],ClientUrlHelpers["revalidate"]takeShouldRevalidateFn | boolean;ClientUrlLoaderRecord.revalidateand both projections'loaderRevalidate(ClientUrlProjectionRoute,ClientUrlProjectionIntercept) carry booleans;registerClientUrlRevalidatorsaccepts a boolean value;runRevalidatetakes booleans alone or in a chain.src/vite/plugins/client-urls-revalidate-hoist.ts— a booleanLiteralis 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 (theprefetchoption 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-*.jsis 0).src/client-urls/server-projection.ts— in the shared route-and-intercept helpers:serializeLoaderOptionsprojects a boolean as itself, andloaderRevalidatorsconverts at the source, so its return type staysShouldRevalidateFn[]andmaterializeLoadersis unchanged: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):src/testing/internal/client-urls-projection.ts— a boolean rides the test projection as itself, as the plugin leaves a literal.Test plan
src/route-definition/__tests__/revalidate-shorthand.test.ts(DSL stores the constant predicate on segments and loader entries, in order; rejectsundefined/null/string/object (revalidate() expects a predicate or a boolean, got <type>);runRevalidatetakes 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-literalHOLD,!import.meta.env.DEV,HOLD ? true : falseand!HOLDhoisted, a module-localconst HOLD = falserejected namingfile: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" pinsprojection.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).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 navigationrevalidate(true)re-runs while the default andrevalidate(false)hold, on an actionrevalidate(true)and the default re-run whilerevalidate(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 }).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.(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-genpasses 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 plusclient-urls-ssr-signals,client-urls-vars):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 usesrevalidate(false)(action hold, param-nav hold, prefetched param-nav hold, no-JS render).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).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).e2e/client-urls-hmr-revalidate.test.tsnew "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-urlsproject 4/4 in each of 3 runs with--retries=0(12/12);test:hmr-routes-local21/21.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 throwinclude() 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 unboundedtextContent()hung on the error page orgotoaborted 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-guardsgreen 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-*.jsis 0).Notes
docs/client-urls.md(now also says a production build checks a literal at its call site and any other argument at group mount; theloaderskill says the same in one line), skillsloader(return shapes),layout,client-urls,intercept,parallel,server-actions; docs sitefeatures/caching.mdx(return shapes),guides/client-urls.mdx,layouts.mdx,intercepting-routes.mdx,parallel-routes.mdx,server-actions.mdx; internalfeature-map.md,feature-file-map.md. CHANGELOG## Unreleased.revalidate(undefined)(and any non-function, non-boolean) inurls()now throws at definition time. Before, it was called per request, logged aTypeErrorand deferred. TypeScript already rejected it.tests/vite-rsc-demofixtures keep the function form; they still work and their suites were not run here.include() could not resolve the server projectionorhas no server registration. It recovers on the next request. It is on main as well.