Skip to content

feat(router)!: transition({ when }) is a browser predicate; intercept from/to; transition: false (#995, #989) - #1006

Merged
ivogt merged 7 commits into
mainfrom
feat/client-transition-when
Sep 30, 2026
Merged

ivogt merged 7 commits into
mainfrom
feat/client-transition-when

Conversation

@ivogt

@ivogt ivogt commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

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:

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

// Before: server-side, revalidate()-shaped args
transition({ when: ({ currentParams }) => currentParams?.n !== "b" });
transition({ when: (ctx) => ctx.get(KeepScroll) === true });

// After: a browser predicate. Inline in urls() (the build hoists it into a client module)
transition({ when: ({ from }) => from.params.n !== "b" });
// ...or read the location state the Link pushed
transition({ when: ({ to }) => KeepScroll.read(to)?.keep === true });
// intercept({ when }): same from/to shape, without state
// Before
intercept("@modal", ".product", ProductModal, {
  when: ({ from, params }) => from.pathname === "/shop" && params.id !== "gift-card",
});
// After
intercept("@modal", ".product", ProductModal, {
  when: ({ from, to }) => from.url.pathname === "/shop" && to.params.id !== "gift-card",
});

End-to-end consumer example

// location-states.ts: shared by the Link and the predicate
export const ProductTab = createLocationState<{ tab: "details" | "reviews" }>();

// urls.tsx
path("/product/:id", ProductPage, { name: "product" }, () => [
  // Animate between products, but not when switching tabs on the same product
  // or when the Link asked to skip it.
  transition({
    when: ({ kind, from, to }) =>
      kind !== "pop" &&
      !(from.routeName === "product" && from.params.id === to.params.id) &&
      ProductTab.read(to)?.tab !== "reviews",
  }),
  loading(<ProductSkeleton />),
]);

// ProductPage.tsx
<Link to={href("product", { id })} state={[ProductTab({ tab: "reviews" })]}>Reviews</Link>
<Link to={href("product", { id: nextId })} transition={false}>Next (no animation)</Link>

Unit test through @rangojs/router/testing:

const { applied, context } = runTransitionWhen(when, {
  from: { url: "/product/1", params: { id: "1" }, routeName: "product" },
  to: { url: "/product/1", params: { id: "1" }, routeName: "product", state: [ProductTab({ tab: "reviews" })] },
});
expect(applied).toBe(false);

Semantics

when returns true when returns false or throws transition: false on the call
push / replace held in startTransition, view transition urgent commit: loading() streams, classes set to "none", no remount same as false; when is not called
pop (back/forward) "navigation-back" classes urgent not applicable (no per-call options)
action, including the error lane with action.error "action" classes urgent not applicable
refresh() / stale revalidation (kind: "revalidate") "navigation" / "action" classes urgent not applicable
intercept commit when is not evaluated when is not evaluated no effect
document load, PE, server when never runs when never runs no effect

When the predicate is decided:

Navigation Decided at to.state
server-route push/replace the canonical commit client-pushed state plus server ctx.setLocationState; middleware state also on a PPR replay HIT
clientUrls() cross-route the optimistic swap; the canonical commit reuses that decision client-pushed state only
clientUrls() same-route the canonical commit as for a server route
pop the history-cache restore or the popstate commit the target entry's state. from.state / from.routeName come from per-entry memory and are undefined after a reload.
action the action commit current state plus action-set state, and action.*
Invalid when Result
a server function, captured server binding, server-only import, import(), import.meta other than .env, or this/arguments in urls() dev startup fails, the build exits 1, and HMR shows the Vite overlay. The error names the route and pattern, plus the binding or import.
a route discovered at runtime the same error is thrown at render; nothing is silently dropped
a non-function in clientUrls() TypeError at definition time
an intercept when that throws no intercept (the full page renders), with console.error and the route name. Before, the request failed.

Code changes

  1. feat(router)!: transition({ when }) is a browser predicate
    • The server strips when at 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.ts decideCommitGatedOff is the one decision helper, used by the canonical commit, the optimistic clientUrls swap, pop, the action lanes and revalidate.
    • Segment rendering keeps <ViewTransition> with "none" classes when gated off.
    • The navigation store keeps per-history-entry memory (LRU of 200, keyed by history.state.key). Def.write / delete / the flash clear refresh that memory.
    • publicRouteName filters internal route keys out of the payload.
    • Route discovery validates when and gives loud errors.
  2. feat(router)!: testing primitives
    • runTransitionWhen(when, { kind, from, to, action }) returns { applied, gatedOff, context }.
    • renderRoute: navigate() is decided as push, and the new refresh() as revalidate.
    • Both use the production decision code.
  3. feat(router): inline transition({ when }) via a build hoist (vite/plugins/transition-when-hoist.ts)
    • Only the router's transition is hoisted: the urls() helper, or the @rangojs/router import, aliased or namespaced.
    • Scope analysis rejects captures. Module imports the literal uses are re-emitted in a virtual client module, ?rango-when=N.
    • load serves exactly what transform recorded.
    • Vitest does not run the hoist.
  4. feat(router)!: intercept({ when }) selects on from/to
    • ServerRouteLocation = { url, params, routeName }.
    • from.params comes from the source route match (nav.effectiveFromMatch).
    • A throwing selector falls back to the full page.
  5. feat(router): per-navigation transition: false: on NavigateOptions, Link (prop and data-transition), the link interceptor and useRouter.
  6. test(consumers): cloudflare-basic's transition-when fixtures move to "use client" modules and inline literals.
    • The e2e contract that counted server evaluations (pprExecCounters.transitionWhen) now asserts the browser-side marker.
    • vite-rsc-demo's intercept selectors are migrated.
    • A new cloudflare-basic intercept fixture is added.
  7. docs:
    • skills: view-transitions (conditional transitions rewritten, prefetch caveat removed), client-urls, route, testing, ppr, intercept, and rango (the rule "a predicate runs where its effect lands");
    • the docs site;
    • feature-map.md and feature-file-map.md;
    • execution-model.md and the semantic matrix PPR4 row;
    • the CHANGELOG.

Why the browser, and not a server when with 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

  • Userland, through @rangojs/router/testing:
    • runTransitionWhen with a dogfood Def.read(ctx.to) (tests/cloudflare-basic/test/transition-when.test.ts);
    • renderRoute navigate / refresh decisions and mount-count contracts: no when applies, a throw gates off, no remount across true -> false -> true;
    • serveShellRequest for the intercept shape, a throwing selector, and public route names.
  • Red on origin/main, green on the branch, e2e dev and production:
  • Review regressions, each red before its fix:
    • unnamed or include-scoped routeName ("$$path__unnamed");
    • pop from.state after Def.write;
    • an urgent commit for gated-off action and revalidate lanes (dev e2e gated-off action commit is urgent on a useActionState form);
    • runTransitionWhen to defaulting to from;
    • no decision for a discarded stale revalidation or an intercept pop;
    • hoist cases (23 red → 47/47);
    • a throwing intercept selector (unit, serveShellRequest, e2e in both apps);
    • routeName typing (@ts-expect-error on a typo).
  • E2e:
    • router test-app: dev 1313 passed (3 flaky, passed on retry), production 1109 passed;
    • semantic matrix, dev and production;
    • cloudflare-basic: dev 450, production 512;
    • vite-rsc-demo: 299 passed;
    • react-experimental: 99 passed, 1 failed (the same failure on origin/main, see Notes);
    • route-types HMR: 21/21, flaky as on main.
  • pnpm run typecheck, pnpm run test:unit:all (router 7030 + rsc 420; every consumer suite), lint, format, check:docs-api, check:e2e-bucketing and check:e2e-parity --strict. *.gen.ts is committed.

Notes

@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 5984799

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
ivogt force-pushed the feat/client-transition-when branch from c5f4168 to 5984799 Compare September 30, 2026 18:22
@ivogt
ivogt merged commit 36798d0 into main Sep 30, 2026
43 checks passed
@ivogt
ivogt deleted the feat/client-transition-when branch September 30, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant