Repository navigation
feat(router)!: transition({ when }) is a browser predicate; intercept from/to; transition: false (#995, #989) - #1006
Merged
Merged
Conversation
commit: |
transition({ when }) now runs in the browser, once per navigation, at the
first commit that presents the destination. The server never calls it: in
urls() it is a "use client" export that crosses Flight as a client
reference; in clientUrls() it is the inline function the definition module
declares. Before, the server evaluated it per request, so a prefetch or a
cache/prerender hit froze the decision to the wrong source, a kept segment's
predicate never ran (#989), and `false` deleted the segment's transition,
which flipped its key and remounted the route (#995).
- Context: { kind, from, to, isAction, action? }. `from`/`to` are
RouteLocation { url, params, routeName, state }; `to` is `from` for
"action" and "revalidate". refresh() reports "revalidate". The error lane
of a failed action decides too, with `action.error`. createLocationState
definitions gain read(location) for `Def.read(ctx.to)`.
- Decision: AND over every committed segment that declares a `when`, kept or
re-sent, deduped by function identity; intercept commits are not
evaluated. A throw counts as false and is logged. A cross-route
clientUrls() navigation decides at the optimistic swap and the canonical
commit reuses it (carried on the navigation transaction); a same-route one
decides at the commit. Back/forward reads the entry left from per-entry
memory in the navigation store.
- `false` makes the navigation an urgent commit (no hold, no view
transition, the loading() skeleton streams); every segment keeps its key
and its <ViewTransition> element with each class "none", so nothing
remounts. Action commits keep their startTransition.
- Server: transition-gate.ts, the PPR pre-handler evaluator and the
_transitionWhen/_pprTransitionDecisions/_gate* request fields are gone.
Stores never hold `when`: resolution strips it, the match records it from
the static entry chain, attachTransitionWhen adds it right before Flight.
Payloads carry metadata.routeName.
- Loud validation: discovery (generateManifestFull validateTransitionWhen)
fails dev startup and the build on a server function or a non-function,
naming the route and pattern; an HMR re-discovery prints it and shows it
in the Vite overlay (re-sent until a discovery succeeds); the routes-manifest
module enables the same check in the app's RSC realm, where
attachTransitionWhen throws it at render instead of dropping the value.
The DSL rejects a non-function with a TypeError; clientUrls() too.
- Semantic matrix PPR4 and docs/internal/execution-model.md: a PPR HIT
carries the predicate as a client reference; no predicate runs there.
Tests: internal units for the decision, the reference/validation module,
attach, discovery validation, the store memory, partial-update, both
bridges and the segment wrapper; router e2e (dev + production) for the
browser context (push, pop, action, error lane, refresh, location state,
prerender, clientUrls same- and cross-route), the #995/#989/prefetch-source
regressions, the dev-startup and build failures, and the HMR overlay.
…en })
runTransitionWhen and renderRoute now exercise the browser decision the
router makes, through the same functions (browser/transition-when.ts).
- runTransitionWhen(when, { kind?, from?, to?, action? }) returns
{ applied, gatedOff, context }. `from`/`to` take a URL string or a partial
RouteLocation; a `state` given as location-state entries ([Def(value)],
what Link/push take) is stored the way a push stores it, so
Def.read(ctx.to) reads it back. `action` takes an imported server action,
an actionId string, or { ref, formData, result, error }, like
runClientRevalidate; `kind` defaults to "action" with an action, "push"
otherwise. The removed request/vars/env/ppr/onError options and the
kept/dropped/whenContext/ctx result fields have no replacement: the
predicate never sees a request.
- renderRoute decides every commit of a spec's transition({ when }):
navigate() and useRouter().push model kind "push", useRouter().replace
"replace", and the new router.refresh() (also useRouter().refresh())
"revalidate" on the current location. The leaf spec's `name` is the
route name on both sides.
- The action-id resolution runClientRevalidate used is shared
(testing/internal/action-id.ts).
Userland tests (src/testing/__tests__/transition-when.test.tsx) pin the
context shape, Def.read(ctx.to), the action and revalidate kinds, the throw
rule, and the mount-count contracts: no remount across true -> false ->
true (with and without viewTransition: false), a when-less transition()
applying, and a throwing when gating off without a remount.
`transition({ when: (ctx) => ... })` is now valid in a server urls() file:
the Vite plugin hoists the function literal into a virtual "use client"
module, so it reaches the browser as a client reference exactly like an
exported predicate. The import form stays for predicates that are shared or
unit-tested.
- vite/plugins/transition-when-hoist.ts, keyed syntactically on
transition({ when: <function literal> }) (React's "use client" is
module-level only). The literal is replaced by an import of
`<file>?rango-when=<index>`: a query on the urls file itself, so relative
imports resolve as they did and an edit invalidates it in the RSC, SSR and
client graphs. `load` serves those modules from the file on disk in every
environment. "use client" modules (clientUrls()) are left alone.
- No captures: a free reference to a module binding that is not an import,
or to an enclosing function's binding, is a TransitionWhenError naming the
variable and file:line, at dev startup, at build and in the HMR overlay.
Referenced imports are re-emitted (type-only ones as `import type`); a
server-only one (node builtins, cloudflare:, server-only, or a module that
imports server-only) fails naming the import. An identifier value that is
a local binding fails here; an import is left for discovery to check.
- Registered in rango() and in the build-time discovery server.
- The discovery error text names the inline form too.
- Vitest does not run the plugin: in unit tests the literal stays a plain
function.
Tests: transform units (hoisted literal, several literals, re-emitted
imports, globals and shadowing, captures, server-only imports, identifier
values, "use client" skip); router e2e (dev + production) where an inline
`when` reading an imported location-state definition decides in the
browser; route-types-hmr cases for republishing an edited literal and for
the capture error in the terminal and the Vite overlay. The invalid-`when`
fixtures now pass a server-module export by name, since a literal is valid.
…ons as transition({ when })
intercept()'s `when` selector now receives `from` and `to` as
{ url, params, routeName } (ServerRouteLocation: RouteLocation without
`state`, since history state never reaches the server), the navigation
shape transition({ when }) uses. `request`, `env` and `segments` are kept.
Breaking: `from.pathname` is `from.url.pathname`; the top-level `params`
(the target's) is `to.params`; `fromRouteName`/`toRouteName` are
`from.routeName`/`to.routeName`. The old fields are gone, with no aliases.
- `from.params` is new: the server matches the source URL (the intercept
source while one is open) to derive `from.routeName` already, and the
match carries its params (nav.effectiveFromMatch).
- ServerRouteLocation is exported from @rangojs/router.
- The clientUrls() synthesized origin selector and every intercept `when`
in the router's e2e apps (test-app, mini, e2e-basic) are migrated.
Tests: match-api units (from/to for a param'd source, including the
intercept-source path); a userland serveShellRequest partial navigation
through the production match, pinning the context keys and selecting on
from.params/to.params; router e2e (dev + production) where from.routeName,
from.params and to.params open or skip the modal.
router.push(url, { transition: false }), router.replace(url, { transition:
false }), navigate(url, { transition: false }) and <Link transition={false}>
present that one navigation without a transition: the commit is urgent and
every <ViewTransition> class resolves to "none", the same result as a
transition({ when }) predicate returning false. No predicate is called.
- NavigateOptions.transition (RouterNavigateOptions inherits it); Link adds
the prop and a data-transition="false" attribute the link interceptor reads.
- navigation-bridge navigate(): the opt-out sets transitionGatedOff on the
transaction, and the optimistic clientUrls() swap decides true without
calling the destination's or a kept segment's predicate. A server redirect
re-navigation carries the option.
- Back/forward, action and revalidation commits are unaffected.
- renderRoute honors it on router.navigate(url, { transition: false }),
useRouter().push/replace and <Link transition={false}>.
Tests: link-interceptor and navigation-bridge unit cases; userland
renderRoute cases (urgent commit suspends to the fallback, predicate never
called, Link and useRouter paths); e2e dev + production on /tx-src and the
clientUrls() cross-route swap, asserting the when log stays empty.
… to from/to
cloudflare-basic:
- /tx-when/:hold/:n declares an inline predicate on the destination's :hold
(hoisted into a client module by the build); the handler-set TxHoldMark
var is gone, since handler state does not exist in the browser.
- /tx-src/:n imports txSrcWhen from a "use client" module
(src/components/transition-when.ts) that reads the source's params and
TxWhenState.read(ctx.to), and logs every call to window.__txWhenLog. New
links: f pushes { animate: false }, g uses <Link transition={false}>.
- /ppr-load-more compares from.url / to.url.
- /ppr-shell/exec-matrix: the predicate runs in the browser, so the server
transitionWhen counter is removed. The middleware now sets PprExecMark
location state; the partial-navigation replay test asserts the predicate
ran once per navigation and read that mark from to.state on a replay HIT.
- tx-when-transition e2e (dev + production) rewritten to the browser
contract: no call on the document load, the logged { kind, from, to },
Def.read(ctx.to), transition: false (predicate never called), and kind
pop on back.
- test/transition-when.test.ts: runTransitionWhen dogfood against the app's
real predicates, including Def.read(ctx.to).
vite-rsc-demo: the shop and blog intercept selectors read from.url.pathname.
… transition: false
- CHANGELOG Unreleased: Breaking entries for the browser-run
transition({ when }) (context, decision points, combination, loud
validation, inline hoist, runTransitionWhen/renderRoute changes) and the
intercept({ when }) from/to shape; Added entry for per-navigation
transition: false.
- Skills: view-transitions rewrites "Conditional transitions" (inline and
"use client" forms, context table with to.state per kind, combination,
false semantics, class-key mapping, per-navigation opt-out, bundle note)
and drops the prefetch/cache caveat; client-urls allows an inline when;
intercept, prerender, migrate-nextjs and comparison use from.url /
from.routeName / to.params; testing documents the new runTransitionWhen
options/result and the Vitest limits (no hoist, no validation); ppr and
route describe the browser decision; hooks/navigation documents
transition: false; rango adds "a predicate runs where its effect lands",
the from/to vs current*/next* vocabulary rule and the throw rule.
- Docs site: view-transitions, intercepting-routes, client-urls,
segments-and-rendering, testing, migrate-from-nextjs, reference/hooks.
- Internal: feature-map (4) and the Route Transitions bullet,
feature-file-map ownership for the new files (transition-when-ref.ts,
browser/transition-when.ts, rsc/attach-transition-when.ts,
vite/plugins/transition-when-hoist.ts, testing/internal/action-id.ts) with
rsc/transition-gate.ts removed; execution-model records that
middleware-set location state reaches to.state on a PPR replay HIT;
route-definition-rules gains the transition when rules; design docs
(caching, shell-fast-path, ppr-shell-resume, client-urls) drop the
server-evaluation wording.
ivogt
force-pushed
the
feat/client-transition-when
branch
from
September 30, 2026 18:22
c5f4168 to
5984799
Compare
This was referenced Oct 1, 2026
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.
Closes #995. Closes #989.
Problem
transition({ when })ran on the server while the route resolved, so its decision was frozen into whichever response carried it:cache()or prerender hit replayed a stored decision.when, so its decision never reached the client (transition: a when() decision for a kept (not re-sent) segment never reaches the client #989).true -> false -> trueremounted the route twice and lost component state (transition: a when() false navigation changes the segment's React key and remounts the route twice #995).The predicate also could not see what it needed. It had no history state, no reliable source page, and no action result on the client.
Before / after
End-to-end consumer example
Unit test through
@rangojs/router/testing:Semantics
whenreturns truewhenreturns false or throwstransition: falseon the callstartTransition, view transitionloading()streams, classes set to"none", no remountwhenis not called"navigation-back"classesaction.error"action"classesrefresh()/ stale revalidation (kind: "revalidate")"navigation"/"action"classeswhenis not evaluatedwhenis not evaluatedwhennever runswhennever runsWhen the predicate is decided:
to.statectx.setLocationState; middleware state also on a PPR replay HITclientUrls()cross-routeclientUrls()same-routefrom.state/from.routeNamecome from per-entry memory and areundefinedafter a reload.action.*when, both kept and re-sent segments (transition: a when() decision for a kept (not re-sent) segment never reaches the client #989).falseand is logged withconsole.error.whenimport(),import.metaother than.env, orthis/argumentsinurls()clientUrls()TypeErrorat definition timewhenthat throwsconsole.errorand the route name. Before, the request failed.Code changes
feat(router)!: transition({ when }) is a browser predicatewhenat resolution (applyViewTransitionDefault), records the refs from the static entry chain on every match, and attaches them right before Flight (attachTransitionWhen) on the partial, full/document, PE, action and action-error payloads. Stores never hold a function.browser/transition-when.tsdecideCommitGatedOffis the one decision helper, used by the canonical commit, the optimisticclientUrlsswap, pop, the action lanes and revalidate.<ViewTransition>with"none"classes when gated off.history.state.key).Def.write/delete/ the flash clear refresh that memory.publicRouteNamefilters internal route keys out of the payload.whenand gives loud errors.feat(router)!: testing primitivesrunTransitionWhen(when, { kind, from, to, action })returns{ applied, gatedOff, context }.renderRoute:navigate()is decided aspush, and the newrefresh()asrevalidate.feat(router): inline transition({ when }) via a build hoist(vite/plugins/transition-when-hoist.ts)transitionis hoisted: theurls()helper, or the@rangojs/routerimport, aliased or namespaced.?rango-when=N.loadserves exactly whattransformrecorded.feat(router)!: intercept({ when }) selects on from/toServerRouteLocation = { url, params, routeName }.from.paramscomes from the source route match (nav.effectiveFromMatch).feat(router): per-navigation transition: false: onNavigateOptions,Link(prop anddata-transition), the link interceptor anduseRouter.test(consumers): cloudflare-basic's transition-when fixtures move to"use client"modules and inline literals.pprExecCounters.transitionWhen) now asserts the browser-side marker.docs:feature-map.mdandfeature-file-map.md;execution-model.mdand the semantic matrix PPR4 row;Why the browser, and not a server
whenwith more inputs: the decision is only correct against the committed page and history entry the click actually leaves. Only the browser has that at commit time, for every path: prefetch, cache HIT, prerender, kept segments, pop and optimistic swaps.Test plan
@rangojs/router/testing:runTransitionWhenwith a dogfoodDef.read(ctx.to)(tests/cloudflare-basic/test/transition-when.test.ts);renderRoutenavigate/refreshdecisions and mount-count contracts: nowhenapplies, a throw gates off, no remount acrosstrue -> false -> true;serveShellRequestfor the intercept shape, a throwing selector, and public route names.b -> a keeps component state, Expected "clicks:2" Received "clicks:0";c -> b ... nothing animates, Expected 1 Received 2;e -> c does not remount, dev Expected 6 Received 8, production Expected 3 Received 4.routeName("$$path__unnamed");from.stateafterDef.write;gated-off action commit is urgenton auseActionStateform);runTransitionWhentodefaulting tofrom;serveShellRequest, e2e in both apps);routeNametyping (@ts-expect-erroron a typo).pnpm run typecheck,pnpm run test:unit:all(router 7030 + rsc 420; every consumer suite), lint, format,check:docs-api,check:e2e-bucketingandcheck:e2e-parity --strict.*.gen.tsis committed.Notes
whenreadingctx.get(),env,currentUrl/nextUrloractionResultmust move to the browser. Read location state set by middleware, the handler or aLinkfromto.state, and action data fromaction.*.from.pathnamebecomesfrom.url.pathname, andparamsbecomesto.params. There are no deprecated aliases.tools/check-bundle-guards.mjsis raised from 45 KB to 46 KB with a documented reason, following feat(router): clientUrls() — client route groups with instant navigation, server authority #812, fix(router): async include() resolves a clientUrls() module directly #848 and feat(router): the default document restores scroll on back/forward #948. Predicate modules land in the client entry chunk, so they should contain only type imports and location-state definitions, and nowindowat module scope.whenstays a plain function. This is documented in the testing skill.comments.test.ts"should disable submit button while posting" is a timezone hydration mismatch.Refererfallback forfrom, which predates this PR;