Skip to content

feat(router): opt out of router-placed ViewTransition via transition({ viewTransition }) - #526

Merged
ivogt merged 1 commit into
mainfrom
view-transition-boundary-optout
Jun 1, 2026
Merged

ivogt merged 1 commit into
mainfrom
view-transition-boundary-optout

Conversation

@ivogt

@ivogt ivogt commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

On experimental React, transition() always wrapped a segment's content in React's <ViewTransition>. That boundary is a cross-fade of the whole outlet/route, and React gives it a unique auto-generated view-transition-name. So a consumer who placed their own targeted <ViewTransition name="hero"> to morph one element got the router's whole-subtree cross-fade running on top of their morph — a double animation they couldn't turn off. There was no way to say "keep view transitions firing on navigation so my own <ViewTransition> animates, but don't place a router cross-fade of your own."

The root cause is that transition() did three coupled jobs, all gated on the same transition presence:

  1. Auto-wrap the segment in <ViewTransition> (the router cross-fade).
  2. Drive the commit through startTransition + addTransitionType — the thing that actually makes React run document.startViewTransition, and what controls Suspense fallbacks (hold vs. skeleton flash).
  3. Hold same-route content via param-agnostic keys (stale-while-revalidate).

A consumer-placed <ViewTransition> only needs job #2 (React's commit walker is origin-agnostic about which component rendered the boundary). Job #1 is purely additive — and unwanted in that case.

What changed for the consumer

transition() now exposes whether the router places its own boundary, without touching driving (#2) or hold (#3):

  • Per-route: transition({ viewTransition: "auto" | false }). false suppresses the router boundary only.
  • App-wide: createRouter({ viewTransition: "auto" | false }). A per-segment value overrides it.

Before → after

// BEFORE — transition({}) drives + holds, but ALSO emits a router cross-fade
// that runs on top of the consumer's <ViewTransition name="hero"> morph.
path("/product/:id", ProductPage, { name: "product" }, () => [
  transition({}),
]);

// AFTER — boundary opt-out: router still drives + holds, but places NO
// cross-fade. Only the consumer's named morph animates.
path("/product/:id", ProductPage, { name: "product" }, () => [
  transition({ viewTransition: false }),
]);

End-to-end consumer example

import { urls } from "@rangojs/router";
import { Link } from "@rangojs/router/client";
import { ViewTransition } from "react";

export const urlpatterns = urls(({ path, transition }) => [
  path("/products", ProductList, { name: "products" }, () => [
    transition({ viewTransition: false }),
  ]),
  path("/products/:id", ProductDetail, { name: "product" }, () => [
    transition({ viewTransition: false }),
  ]),
]);

// The boundary lives in the component, exactly where the morph is wanted.
// Both pages name the same element, so it flies between list and detail.
function ProductList() {
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          <Link to={`/products/${p.id}`}>
            <ViewTransition name={`cover-${p.id}`}>
              <img src={p.cover} />
            </ViewTransition>
          </Link>
        </li>
      ))}
    </ul>
  );
}
function ProductDetail({ id, cover }) {
  return (
    <ViewTransition name={`cover-${id}`}>
      <img src={cover} className="hero" />
    </ViewTransition>
  );
}

Navigating list ↔ detail morphs only the cover image (the consumer's named boundary). The router contributes the startTransition (so the morph fires and loaders don't flash a skeleton) but no competing whole-page cross-fade. To opt the whole app into this model: createRouter({ viewTransition: false }).

Semantics

transition() means "drive this navigation with startTransition"; the viewTransition flag decides whether a router <ViewTransition> is layered on. A view transition cannot fire without a startTransition (React only activates <ViewTransition> inside a Transition), so the meaningful states are:

Config Router boundary startTransition (hold, no skeleton flash) Consumer <ViewTransition name>
no transition() — no does not fire on nav
transition({}) / { viewTransition: "auto" } yes (cross-fade) yes fires, under the router cross-fade
transition({ viewTransition: false }) none yes fires alone
global viewTransition: false + transition() none yes fires alone

Precedence — a bare transition({}) inherits the router default; an explicit per-route value wins:

per-route router default result
transition({}) "auto" (default) ST + VT
transition({}) false ST only
transition({ viewTransition: "auto" }) false ST + VT (per-route wins)
transition({ viewTransition: false }) "auto" ST only (per-route wins)

On stable React there is no <ViewTransition>, so viewTransition: false is a visual no-op — but driving and hold still apply, identical to transition({}). Unchanged: shouldStartViewTransition and inTransitionScope still key off transition() presence, so { viewTransition: false } keeps both driving and hold.

Code changes

  • types/segments.ts — add viewTransition?: "auto" | false to TransitionConfig.
  • router/router-options.ts + router.ts — add viewTransition to RangoOptions (default "auto") and thread it onto segmentDeps.viewTransitionDefault.
  • segment-system.tsx — the one render gate: if (ReactViewTransition && transition && transition.viewTransition !== false); strip the router-only viewTransition prop before it reaches React's <ViewTransition>.
  • router/segment-resolution/view-transition-default.ts (new) — applyViewTransitionDefault stamps the global default onto each segment's transition config during resolution (only false is stamped; unset/"auto" is left as-is). Applied in fresh.ts and revalidation.ts. Why here: transition already serializes in cache/segment-codec.ts, so resolving the default server-side means the client reads a concrete boundary decision with zero new client plumbing, and server/client never diverge. Its own module (not helpers.ts) because several resolution tests mock helpers.ts with explicit export lists.

The render gate was chosen as the single decoupling point because the other two jobs (shouldStartViewTransition in browser/partial-update.ts, inTransitionScope in segment-system.tsx) already key off transition presence — which { viewTransition: false } still satisfies — so driving and hold survive by construction, and the intercept/modal guard and PE/JS parity are inherited untouched.

Test plan

  • Unit — view-transition-default.test.ts: per-segment value wins, global default stamps only when false + unset, no input mutation. (3279 unit tests pass.)
  • e2e — experimental React, dev + production (tests/react-experimental, view-transition-optout.test.ts): a document.startViewTransition recorder proves auto fires the router boundary, viewTransition: false suppresses it, and a consumer-placed <ViewTransition> still fires under the opt-out.
  • e2e — global default, dev + production: app built with VITE_RANGO_VT=false exercises createRouter({ viewTransition: false }) end to end through the real router; a bare transition({}) route gets no boundary while a per-route transition({ viewTransition: "auto" }) overrides the global.
  • e2e — stable React, dev + production (e2e/test-app, same-route-nav.test.ts): transition({ viewTransition: false }) still holds content with no skeleton flash (driving + hold survive the flag).
  • pnpm run typecheck
  • pnpm run lint
  • pnpm run format
  • router.named-routes.gen.ts regenerated for both apps.

Notes

  • Docs: the view-transitions skill now leads with the startTransition × <ViewTransition> matrix and the precedence table; the same purpose summary + precedence are in the transition() DSL JSDoc (helpers-types.ts, path-helper-types.ts) and feature-map.md.
  • Test-only hook: tests/react-experimental/src/router.tsx reads import.meta.env.VITE_RANGO_VT to flip the global for the global-default e2e (defaults to "auto", so no other test is affected). It needs a fresh build per value — CI always rebuilds; a local run with TEST_SKIP_BUILD would serve stale dist (same contract as every build-mode test in that app).
  • Open design fork (deferred): whether a bare transition({}) should default to ST + VT (current) or ST-only with VT inferred from phase classes. Docs reflect the current default; flipping it is a small follow-up.
  • Deferred follow-up: Link-level / Next-style named transition triggers; the viewTransition field + global default are forward-compatible with it.
  • Internal naming nit (not done here): shouldStartViewTransition() now gates the startTransition wrap even when no boundary is placed, so a rename (e.g. shouldDriveTransition) would read truer.

…{ viewTransition })

On experimental React, transition() always wrapped a segment in React's
<ViewTransition>, producing a router-owned cross-fade on top of any
consumer-placed <ViewTransition>. There was no way to keep view transitions
firing on navigation while letting the consumer own the boundaries.

transition({ viewTransition: false }) now suppresses the router boundary
while keeping the two behaviors a consumer-placed <ViewTransition> depends on:
the startTransition driving (so React runs document.startViewTransition and
the consumer's named morph animates) and the same-route content hold (no
skeleton flash). createRouter({ viewTransition: "auto" | false }) sets the
app-wide default; a per-segment value overrides it.

The global default is resolved into each segment's transition config during
resolution (applyViewTransitionDefault in segment-resolution/
view-transition-default.ts, applied in fresh.ts and revalidation.ts) so the
render gate (segment-system.tsx, transition.viewTransition !== false) reads the
boundary decision off the segment on both server and client without threading
the option to the client — only `false` is stamped; unset/"auto" means wrap.
shouldStartViewTransition and inTransitionScope are untouched: they key off
transition() presence, which { viewTransition: false } still satisfies.

Tests:
- unit: applyViewTransitionDefault resolution logic (per-segment wins, global
  default stamps only when false + unset, no mutation).
- e2e (experimental, dev+prod): startViewTransition spy proves auto fires, the
  per-route flag suppresses the router boundary, and a consumer <ViewTransition>
  still fires under the opt-out.
- e2e (experimental, dev+prod): the global createRouter({ viewTransition:false })
  default, exercised end to end through the real router (app built with
  VITE_RANGO_VT=false) — a bare transition({}) route gets no boundary, while a
  per-route transition({ viewTransition: "auto" }) overrides the global.
- e2e (test-app stable, dev+prod): transition({ viewTransition: false }) still
  holds content with no skeleton flash.
@pkg-pr-new

pkg-pr-new Bot commented Jun 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: b634142

@ivogt
ivogt merged commit 269b75d into main Jun 1, 2026
25 checks passed
@ivogt
ivogt deleted the view-transition-boundary-optout branch June 1, 2026 04:37
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