Repository navigation
feat(router): opt out of router-placed ViewTransition via transition({ viewTransition }) - #526
Merged
Merged
Conversation
…{ 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.
commit: |
6 tasks
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
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-generatedview-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 sametransitionpresence:<ViewTransition>(the router cross-fade).startTransition+addTransitionType— the thing that actually makes React rundocument.startViewTransition, and what controls Suspense fallbacks (hold vs. skeleton flash).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):transition({ viewTransition: "auto" | false }).falsesuppresses the router boundary only.createRouter({ viewTransition: "auto" | false }). A per-segment value overrides it.Before → after
End-to-end consumer example
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 withstartTransition"; theviewTransitionflag decides whether a router<ViewTransition>is layered on. A view transition cannot fire without astartTransition(React only activates<ViewTransition>inside a Transition), so the meaningful states are:startTransition(hold, no skeleton flash)<ViewTransition name>transition()transition({})/{ viewTransition: "auto" }transition({ viewTransition: false })viewTransition: false+transition()Precedence — a bare
transition({})inherits the router default; an explicit per-route value wins:transition({})"auto"(default)transition({})falsetransition({ viewTransition: "auto" })falsetransition({ viewTransition: false })"auto"On stable React there is no
<ViewTransition>, soviewTransition: falseis a visual no-op — but driving and hold still apply, identical totransition({}). Unchanged:shouldStartViewTransitionandinTransitionScopestill key offtransition()presence, so{ viewTransition: false }keeps both driving and hold.Code changes
types/segments.ts— addviewTransition?: "auto" | falsetoTransitionConfig.router/router-options.ts+router.ts— addviewTransitiontoRangoOptions(default"auto") and thread it ontosegmentDeps.viewTransitionDefault.segment-system.tsx— the one render gate:if (ReactViewTransition && transition && transition.viewTransition !== false); strip the router-onlyviewTransitionprop before it reaches React's<ViewTransition>.router/segment-resolution/view-transition-default.ts(new) —applyViewTransitionDefaultstamps the global default onto each segment's transition config during resolution (onlyfalseis stamped; unset/"auto"is left as-is). Applied infresh.tsandrevalidation.ts. Why here:transitionalready serializes incache/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 (nothelpers.ts) because several resolution tests mockhelpers.tswith explicit export lists.The render gate was chosen as the single decoupling point because the other two jobs (
shouldStartViewTransitioninbrowser/partial-update.ts,inTransitionScopeinsegment-system.tsx) already key offtransitionpresence — 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
view-transition-default.test.ts: per-segment value wins, global default stamps only whenfalse+ unset, no input mutation. (3279 unit tests pass.)tests/react-experimental,view-transition-optout.test.ts): adocument.startViewTransitionrecorder provesautofires the router boundary,viewTransition: falsesuppresses it, and a consumer-placed<ViewTransition>still fires under the opt-out.VITE_RANGO_VT=falseexercisescreateRouter({ viewTransition: false })end to end through the real router; a baretransition({})route gets no boundary while a per-routetransition({ viewTransition: "auto" })overrides the global.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 typecheckpnpm run lintpnpm run formatrouter.named-routes.gen.tsregenerated for both apps.Notes
startTransition×<ViewTransition>matrix and the precedence table; the same purpose summary + precedence are in thetransition()DSL JSDoc (helpers-types.ts,path-helper-types.ts) andfeature-map.md.tests/react-experimental/src/router.tsxreadsimport.meta.env.VITE_RANGO_VTto 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 withTEST_SKIP_BUILDwould serve staledist(same contract as every build-mode test in that app).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.viewTransitionfield + global default are forward-compatible with it.shouldStartViewTransition()now gates thestartTransitionwrap even when no boundary is placed, so a rename (e.g.shouldDriveTransition) would read truer.