From 9b0cd8437388a0880f239fc8d3f7545b0ca0b22b Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Mon, 23 Mar 2026 15:36:18 -0400 Subject: [PATCH 1/5] Add basic ViewTransition callback tests --- .../__tests__/ReactDOMViewTransition-test.js | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js index 1c5b43a18acd..8fb3473a7f87 100644 --- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js @@ -17,6 +17,7 @@ let ViewTransition; let act; let assertLog; let Scheduler; +let startTransition; let textCache; describe('ReactDOMViewTransition', () => { @@ -31,6 +32,7 @@ describe('ReactDOMViewTransition', () => { assertLog = require('internal-test-utils').assertLog; Suspense = React.Suspense; ViewTransition = React.ViewTransition; + startTransition = React.startTransition; if (gate(flags => flags.enableSuspenseList)) { SuspenseList = React.unstable_SuspenseList; } @@ -176,4 +178,288 @@ describe('ReactDOMViewTransition', () => { expect(container.textContent).toContain('Card 2'); expect(container.textContent).toContain('Card 3'); }); + + describe('ViewTransition event callbacks', () => { + let originalGetBoundingClientRect; + let originalGetAnimations; + let originalAnimate; + let originalStartViewTransition; + + beforeEach(() => { + // Save originals + originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + originalGetAnimations = Element.prototype.getAnimations; + originalAnimate = Element.prototype.animate; + originalStartViewTransition = document.startViewTransition; + + // Mock CSS.escape if it doesn't exist + if (typeof CSS === 'undefined') { + global.CSS = {escape: str => str}; + } else if (!CSS.escape) { + CSS.escape = str => str; + } + + // Mock document.fonts + if (!document.fonts) { + Object.defineProperty(document, 'fonts', { + value: {status: 'loaded', ready: Promise.resolve()}, + configurable: true, + }); + } + + // Mock getAnimations on Element.prototype (Web Animations API) + Element.prototype.getAnimations = function () { + return []; + }; + + // Mock animate on Element.prototype (Web Animations API) + Element.prototype.animate = function () { + return {cancel() {}, finished: Promise.resolve()}; + }; + + // Mock getBoundingClientRect to return content-length-based sizes + // so that hasInstanceChanged can detect updates when text changes. + Element.prototype.getBoundingClientRect = function () { + const text = this.textContent || ''; + const width = text.length * 10 + 10; + const height = 20; + return new DOMRect(0, 0, width, height); + }; + + // Mock document.startViewTransition + document.startViewTransition = function ({update}) { + update(); + return { + ready: Promise.resolve(), + finished: Promise.resolve(), + skipTransition() {}, + }; + }; + }); + + afterEach(() => { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + Element.prototype.getAnimations = originalGetAnimations; + Element.prototype.animate = originalAnimate; + if (originalStartViewTransition) { + document.startViewTransition = originalStartViewTransition; + } else { + delete document.startViewTransition; + } + }); + + // @gate enableViewTransition + it('fires onEnter when a ViewTransition mounts', async () => { + const onEnter = jest.fn(); + const startViewTransitionSpy = jest.fn(document.startViewTransition); + document.startViewTransition = startViewTransitionSpy; + + function App({show}) { + if (!show) { + return null; + } + return ( + +
Hello
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Initial render without the ViewTransition + await act(() => { + root.render(); + }); + expect(onEnter).not.toHaveBeenCalled(); + expect(startViewTransitionSpy).not.toHaveBeenCalled(); + + // Mount the ViewTransition inside startTransition + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(startViewTransitionSpy).toHaveBeenCalled(); + expect(onEnter).toHaveBeenCalledTimes(1); + }); + + // @gate enableViewTransition + it('fires onExit when a ViewTransition unmounts', async () => { + const onExit = jest.fn(); + + function App({show}) { + if (!show) { + return null; + } + return ( + +
Goodbye
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Initial render with the ViewTransition + await act(() => { + startTransition(() => { + root.render(); + }); + }); + expect(onExit).not.toHaveBeenCalled(); + + // Unmount the ViewTransition inside startTransition + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(onExit).toHaveBeenCalledTimes(1); + }); + + // @gate enableViewTransition + it('fires onUpdate when content inside a ViewTransition changes', async () => { + const onUpdate = jest.fn(); + const onEnter = jest.fn(); + + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Initial render + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + onEnter.mockClear(); + expect(onUpdate).not.toHaveBeenCalled(); + + // Update content inside startTransition (different text length + // produces different getBoundingClientRect values in our mock) + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + // onEnter should NOT fire on an update + expect(onEnter).not.toHaveBeenCalled(); + }); + + // @gate enableViewTransition + it('fires onShare for paired named transitions instead of onEnter/onExit', async () => { + const onShareA = jest.fn(); + const onExitA = jest.fn(); + const onShareB = jest.fn(); + const onEnterB = jest.fn(); + + function App({page}) { + if (page === 'a') { + return ( + +
Page A
+
+ ); + } + return ( + +
Page B
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Render page A + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Clear any enter callbacks from initial mount + onShareA.mockClear(); + onExitA.mockClear(); + onShareB.mockClear(); + onEnterB.mockClear(); + + // Switch from page A to page B inside startTransition + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // onShare should fire on the exiting side (page A) + expect(onShareA).toHaveBeenCalledTimes(1); + // onExit should NOT fire when share takes precedence + expect(onExitA).not.toHaveBeenCalled(); + // onEnter should NOT fire on the entering side when paired + expect(onEnterB).not.toHaveBeenCalled(); + }); + + // @gate enableViewTransition + it('fires onEnter when Suspense content resolves', async () => { + const onEnter = jest.fn(); + + function App() { + return ( + + Loading...}> +
+ +
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Initial render - content suspends + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + assertLog(['Suspend! [Loaded]', 'Suspend! [Loaded]']); + // onEnter fires for the fallback appearing + const enterCallsAfterFallback = onEnter.mock.calls.length; + onEnter.mockClear(); + + // Resolve the suspended content + await act(() => { + resolveText('Loaded'); + }); + assertLog(['Loaded']); + + expect(container.textContent).toBe('Loaded'); + // The reveal of the resolved content should trigger enter + // (or it may have triggered on the initial fallback mount) + expect( + onEnter.mock.calls.length + enterCallsAfterFallback, + ).toBeGreaterThanOrEqual(1); + }); + }); }); From 156ac92e889df7f92b2eb9421e8a8846d19b6cf2 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 24 Mar 2026 14:07:20 -0400 Subject: [PATCH 2/5] Add experimental enableViewTransitionNested flag to allow nested VT triggers based on transition types --- .../__tests__/ReactDOMViewTransition-test.js | 198 ++++++++++++++++++ .../src/ReactFiberCommitViewTransitions.js | 102 ++++++++- .../src/ReactFiberCompleteWork.js | 14 ++ .../react-reconciler/src/ReactFiberFlags.js | 6 + packages/shared/ReactFeatureFlags.js | 2 + .../ReactFeatureFlags.native-fb-dynamic.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 13 files changed, 329 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js index 8fb3473a7f87..f3b5dd1a21dc 100644 --- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js @@ -18,6 +18,7 @@ let act; let assertLog; let Scheduler; let startTransition; +let addTransitionType; let textCache; describe('ReactDOMViewTransition', () => { @@ -33,6 +34,7 @@ describe('ReactDOMViewTransition', () => { Suspense = React.Suspense; ViewTransition = React.ViewTransition; startTransition = React.startTransition; + addTransitionType = React.addTransitionType; if (gate(flags => flags.enableSuspenseList)) { SuspenseList = React.unstable_SuspenseList; } @@ -461,5 +463,201 @@ describe('ReactDOMViewTransition', () => { onEnter.mock.calls.length + enterCallsAfterFallback, ).toBeGreaterThanOrEqual(1); }); + + // @gate enableViewTransition + it('does not fire onExit/onEnter for nested ViewTransitions without type match', async () => { + const onOuterExit = jest.fn(); + const onNestedExit = jest.fn(); + const onOuterEnter = jest.fn(); + const onNestedEnter = jest.fn(); + + function App({page}) { + if (page === 'feed') { + return ( + +
+ +
Item 1
+
+
+
+ ); + } + return ( + +
+ +
Details
+
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Render feed page + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Clear initial callbacks + onOuterExit.mockClear(); + onNestedExit.mockClear(); + onOuterEnter.mockClear(); + onNestedEnter.mockClear(); + + // Switch to details page + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Outer VT exit fires + expect(onOuterExit).toHaveBeenCalledTimes(1); + // Nested VT exit does NOT fire + expect(onNestedExit).not.toHaveBeenCalled(); + // Outer VT enter fires + expect(onOuterEnter).toHaveBeenCalledTimes(1); + // Nested VT enter does NOT fire + expect(onNestedEnter).not.toHaveBeenCalled(); + }); + + // @gate enableViewTransition && enableViewTransitionNested + it('fires nested onExit/onEnter only on transition type match', async () => { + const onOuterExit = jest.fn(); + const onNestedExit = jest.fn(); + const onOuterEnter = jest.fn(); + const onNestedEnter = jest.fn(); + const onStringExit = jest.fn(); + const onStringEnter = jest.fn(); + + function App({page}) { + if (page === 'feed') { + return ( + +
+ +
Item 1
+
+ +
Item 2
+
+
+
+ ); + } + return ( + +
+ +
Detail Item
+
+ +
Detail Item 2
+
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Render feed page + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Clear initial callbacks + onOuterExit.mockClear(); + onNestedExit.mockClear(); + onOuterEnter.mockClear(); + onNestedEnter.mockClear(); + onStringExit.mockClear(); + onStringEnter.mockClear(); + + // Switch to details page with 'nav' transition type + await act(() => { + startTransition(() => { + addTransitionType('nav'); + root.render(); + }); + }); + + // Outer VT exit fires (top-level always fires) + expect(onOuterExit).toHaveBeenCalledTimes(1); + // Nested VT with type match fires + expect(onNestedExit).toHaveBeenCalledTimes(1); + // Nested VT with plain string exit does NOT fire (no type match) + expect(onStringExit).not.toHaveBeenCalled(); + // Outer VT enter fires (top-level always fires) + expect(onOuterEnter).toHaveBeenCalledTimes(1); + // Nested VT with type match fires + expect(onNestedEnter).toHaveBeenCalledTimes(1); + // Nested VT with plain string enter does NOT fire (no type match) + expect(onStringEnter).not.toHaveBeenCalled(); + }); + + // @gate enableViewTransition && enableViewTransitionNested + it('nested exit respects transition type filtering', async () => { + const onNestedExit1 = jest.fn(); + const onNestedExit2 = jest.fn(); + + function App({page}) { + if (page === 'feed') { + return ( + +
+ +
Item 1
+
+ +
Item 2
+
+
+
+ ); + } + return ( + +
Details
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Switch with 'nav' transition type + await act(() => { + startTransition(() => { + addTransitionType('nav'); + root.render(); + }); + }); + + // First nested VT matches 'nav' type, so exit fires + expect(onNestedExit1).toHaveBeenCalledTimes(1); + // Second nested VT matches 'other' type (not active), default is 'none', so exit does NOT fire + expect(onNestedExit2).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 760270010dbc..207d52ec2e15 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -7,7 +7,7 @@ * @flow */ -import type {ViewTransitionProps} from 'shared/ReactTypes'; +import type {ViewTransitionClass, ViewTransitionProps} from 'shared/ReactTypes'; import type {Instance, InstanceMeasurement, Props} from './ReactFiberConfig'; import type {Fiber} from './ReactInternalTypes'; import type {ViewTransitionState} from './ReactFiberViewTransitionComponent'; @@ -21,6 +21,7 @@ import { NoFlags, Update, ViewTransitionStatic, + ViewTransitionStaticNested, AffectedParentLayout, ViewTransitionNamedStatic, } from './ReactFiberFlags'; @@ -37,6 +38,7 @@ import { import { scheduleViewTransitionEvent, scheduleGestureTransitionEvent, + getPendingTransitionTypes, } from './ReactFiberWorkLoop'; import { getViewTransitionName, @@ -47,6 +49,7 @@ import { enableComponentPerformanceTrack, enableProfilerTimer, enableViewTransitionForPersistenceMode, + enableViewTransitionNested, } from 'shared/ReactFeatureFlags'; export let shouldStartViewTransition: boolean = false; @@ -324,6 +327,46 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { } } +function commitNestedEnterViewTransitions( + parent: Fiber, + gesture: boolean, +): void { + let child = parent.child; + while (child !== null) { + if (child.tag === OffscreenComponent && child.memoizedState !== null) { + // Skip hidden subtrees. + } else if (child.tag === ViewTransitionComponent) { + const state: ViewTransitionState = child.stateNode; + const props: ViewTransitionProps = child.memoizedProps; + if (!state.paired && hasTransitionTypeMatch(props.enter)) { + const name = getViewTransitionName(props, state); + const className: ?string = getViewTransitionClassName( + props.default, + props.enter, + ); + if (className !== 'none') { + applyViewTransitionToHostInstances( + child, + name, + className, + null, + false, + ); + if (gesture) { + scheduleGestureTransitionEvent(child, props.onGestureEnter); + } else { + scheduleViewTransitionEvent(child, props.onEnter); + } + } + } + commitNestedEnterViewTransitions(child, gesture); + } else if ((child.subtreeFlags & ViewTransitionStaticNested) !== NoFlags) { + commitNestedEnterViewTransitions(child, gesture); + } + child = child.sibling; + } +} + export function commitEnterViewTransitions( placement: Fiber, gesture: boolean, @@ -364,6 +407,9 @@ export function commitEnterViewTransitions( } else { commitAppearingPairViewTransitions(placement); } + if (enableViewTransitionNested) { + commitNestedEnterViewTransitions(placement, gesture); + } } else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) { let child = placement.child; while (child !== null) { @@ -446,6 +492,57 @@ function commitDeletedPairViewTransitions(deletion: Fiber): void { } } +// Check if a ViewTransitionClass is a per-type object and has at least one +// active transition type that matches a key in the object. This is used to +// determine whether nested ViewTransitions should fire exit/enter animations. +function hasTransitionTypeMatch(classByType: ?ViewTransitionClass): boolean { + if (classByType == null || typeof classByType === 'string') { + return false; + } + const activeTypes = getPendingTransitionTypes(); + if (activeTypes !== null) { + for (let i = 0; i < activeTypes.length; i++) { + if (classByType[activeTypes[i]] != null) { + return true; + } + } + } + return false; +} + +function commitNestedExitViewTransitions(parent: Fiber): void { + let child = parent.child; + while (child !== null) { + if (child.tag === OffscreenComponent && child.memoizedState !== null) { + // Skip hidden subtrees. + } else if (child.tag === ViewTransitionComponent) { + const state: ViewTransitionState = child.stateNode; + const props: ViewTransitionProps = child.memoizedProps; + if (!state.paired && hasTransitionTypeMatch(props.exit)) { + const name = getViewTransitionName(props, state); + const className: ?string = getViewTransitionClassName( + props.default, + props.exit, + ); + if (className !== 'none') { + applyViewTransitionToHostInstances( + child, + name, + className, + null, + false, + ); + scheduleViewTransitionEvent(child, props.onExit); + } + } + commitNestedExitViewTransitions(child); + } else if ((child.subtreeFlags & ViewTransitionStaticNested) !== NoFlags) { + commitNestedExitViewTransitions(child); + } + child = child.sibling; + } +} + export function commitExitViewTransitions(deletion: Fiber): void { if (deletion.tag === ViewTransitionComponent) { const props: ViewTransitionProps = deletion.memoizedProps; @@ -493,6 +590,9 @@ export function commitExitViewTransitions(deletion: Fiber): void { // Look for more pairs deeper in the tree. commitDeletedPairViewTransitions(deletion); } + if (enableViewTransitionNested) { + commitNestedExitViewTransitions(deletion); + } } else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) { let child = deletion.child; while (child !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 9b8c4a21bd8a..1b73d6073739 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -40,6 +40,7 @@ import { passChildrenWhenCloningPersistedNodes, disableLegacyMode, enableViewTransition, + enableViewTransitionNested, enableSuspenseyImages, } from 'shared/ReactFeatureFlags'; @@ -98,6 +99,7 @@ import { ShouldSuspendCommit, Cloned, ViewTransitionStatic, + ViewTransitionStaticNested, Hydrate, PortalStatic, } from './ReactFiberFlags'; @@ -2060,6 +2062,18 @@ function completeWork( // bubble up to the parent tree to indicate that there's a child that // might need an exit View Transition upon unmount. workInProgress.flags |= ViewTransitionStatic; + if (enableViewTransitionNested) { + const props = workInProgress.pendingProps; + if ( + (props.enter != null && typeof props.enter !== 'string') || + (props.exit != null && typeof props.exit !== 'string') + ) { + workInProgress.flags |= ViewTransitionStaticNested; + } else { + // Clear if enter/exit type configs were removed in an update. + workInProgress.flags &= ~ViewTransitionStaticNested; + } + } bubbleProperties(workInProgress); } return null; diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 9f85897fb05c..30b4e10e883c 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -83,6 +83,11 @@ export const ViewTransitionNamedStatic = // ViewTransitionStatic tracks whether there are an ViewTransition components from // the nearest HostComponent down. It resets at every HostComponent level. export const ViewTransitionStatic = /* */ 0b0000010000000000000000000000000; +// ViewTransitionStaticNested tracks whether there are ViewTransition components +// with type-based enter/exit configs. Unlike ViewTransitionStatic, this is NOT +// cleared by HostComponents so it can be used to skip subtrees in nested walks. +export const ViewTransitionStaticNested = /* */ 0b1000000000000000000000000000000; + // Tracks whether a HostPortal is present in the tree. export const PortalStatic = /* */ 0b0000100000000000000000000000000; @@ -140,6 +145,7 @@ export const StaticMask = RefStatic | MaySuspendCommit | ViewTransitionStatic | + ViewTransitionStaticNested | ViewTransitionNamedStatic | PortalStatic | Forked; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ba4a7c52e3d3..df79eec724e8 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -80,6 +80,8 @@ export const enableTaint = __EXPERIMENTAL__; export const enableViewTransition: boolean = true; +export const enableViewTransitionNested: boolean = false; + export const enableViewTransitionForPersistenceMode: boolean = false; export const enableGestureTransition = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index 36774ad94d9f..68a605f7e2fa 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -27,3 +27,4 @@ export const enableFragmentRefsInstanceHandles = __VARIANT__; export const enableEffectEventMutationPhase = __VARIANT__; export const enableFragmentRefsTextNodes = __VARIANT__; export const enableViewTransitionForPersistenceMode = __VARIANT__; +export const enableViewTransitionNested = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 0e43e34d0009..3f7d6d89a982 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -29,6 +29,7 @@ export const { enableFragmentRefsInstanceHandles, enableFragmentRefsTextNodes, enableViewTransitionForPersistenceMode, + enableViewTransitionNested, } = dynamicFlags; // The rest of the flags are static for better dead code elimination. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 035bf2a75dd0..997765bde2cb 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -59,6 +59,7 @@ export const enableYieldingBeforePassive: boolean = false; export const enableThrottledScheduling: boolean = false; export const enableViewTransition: boolean = true; +export const enableViewTransitionNested: boolean = false; export const enableViewTransitionForPersistenceMode: boolean = false; export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 76597e0cbb01..6b3d4e5fe8f2 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -60,6 +60,7 @@ export const enableYieldingBeforePassive: boolean = true; export const enableThrottledScheduling: boolean = false; export const enableViewTransition: boolean = true; +export const enableViewTransitionNested: boolean = false; export const enableViewTransitionForPersistenceMode: boolean = false; export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 8022dd8e2254..abb5951edb6a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -55,6 +55,7 @@ export const transitionLaneExpirationMs = 5000; export const enableYieldingBeforePassive = false; export const enableThrottledScheduling = false; export const enableViewTransition = true; +export const enableViewTransitionNested = false; export const enableViewTransitionForPersistenceMode = false; export const enableGestureTransition = false; export const enableScrollEndPolyfill = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 271c464daa60..dd84f5627a4e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -66,6 +66,7 @@ export const enableYieldingBeforePassive: boolean = false; export const enableThrottledScheduling: boolean = false; export const enableViewTransition: boolean = true; +export const enableViewTransitionNested: boolean = false; export const enableViewTransitionForPersistenceMode: boolean = false; export const enableGestureTransition: boolean = false; export const enableScrollEndPolyfill: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 1646c834ef41..7fe7989a6efb 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -30,6 +30,7 @@ export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__; export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__; export const enableViewTransition: boolean = __VARIANT__; +export const enableViewTransitionNested: boolean = __VARIANT__; export const enableScrollEndPolyfill: boolean = __VARIANT__; export const enableFragmentRefs: boolean = __VARIANT__; export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index a07f34414217..33a45a14a882 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -28,6 +28,7 @@ export const { syncLaneExpirationMs, transitionLaneExpirationMs, enableViewTransition, + enableViewTransitionNested, enableScrollEndPolyfill, enableFragmentRefs, enableFragmentRefsScrollIntoView, From 11e71b4c8328b84aa556508f7b10f8e71c350334 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 24 Mar 2026 14:07:40 -0400 Subject: [PATCH 3/5] Add nested example to view-transition fixture --- fixtures/view-transition/server/index.js | 6 +- .../src/components/NestedExit.css | 238 ++++++++++++++++++ .../src/components/NestedExit.js | 132 ++++++++++ .../view-transition/src/components/Page.js | 2 + .../src/components/SwipeRecognizer.js | 13 +- 5 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 fixtures/view-transition/src/components/NestedExit.css create mode 100644 fixtures/view-transition/src/components/NestedExit.js diff --git a/fixtures/view-transition/server/index.js b/fixtures/view-transition/server/index.js index e13d4706b9ef..344463d56775 100644 --- a/fixtures/view-transition/server/index.js +++ b/fixtures/view-transition/server/index.js @@ -20,12 +20,14 @@ if (process.env.NODE_ENV === 'development') { for (var key in require.cache) { delete require.cache[key]; } - import('./render.js').then(({default: render}) => { + import('./render.js').then(mod => { + const render = mod.default.__esModule ? mod.default.default : mod.default; render(req.url, res); }); }); } else { - import('./render.js').then(({default: render}) => { + import('./render.js').then(mod => { + const render = mod.default.__esModule ? mod.default.default : mod.default; app.get('/', function (req, res) { render(req.url, res); }); diff --git a/fixtures/view-transition/src/components/NestedExit.css b/fixtures/view-transition/src/components/NestedExit.css new file mode 100644 index 000000000000..a5c0157b56e4 --- /dev/null +++ b/fixtures/view-transition/src/components/NestedExit.css @@ -0,0 +1,238 @@ +.nested-exit-demo { + width: 300px; + min-height: 280px; + background: #f5f5f5; + border-radius: 10px; + padding: 20px; + margin-top: 20px; +} + +.feed-item { + background: #fff; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 8px; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.feed-item:hover { + background: #f0f0f0; +} + +.feed-item h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.feed-item p { + margin: 0; + font-size: 13px; + color: #666; +} + +.detail-view { + background: #fff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.detail-view h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.detail-view p { + margin: 0; + font-size: 13px; + color: #666; +} + +.back-button { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; +} + +/* Directional exit: posts above go up, posts below go down */ +@keyframes nested-exit-up { + from { + opacity: 1; + translate: 0 0; + } + to { + opacity: 0; + translate: 0 -60px; + } +} + +@keyframes nested-exit-down { + from { + opacity: 1; + translate: 0 0; + } + to { + opacity: 0; + translate: 0 60px; + } +} + +::view-transition-old(.nested-exit-up):only-child { + animation: nested-exit-up 600ms ease-out forwards; +} + +::view-transition-old(.nested-exit-down):only-child { + animation: nested-exit-down 600ms ease-out forwards; +} + +/* Forward shared: delayed until exits finish */ +::view-transition-group(.nested-shared-post-forward) { + animation-duration: 700ms; + animation-delay: 300ms; + animation-timing-function: ease-in-out; + animation-fill-mode: both; +} + +::view-transition-old(.nested-shared-post-forward) { + animation-delay: 300ms; + animation-duration: 700ms; + animation-fill-mode: both; +} + +::view-transition-new(.nested-shared-post-forward) { + animation-delay: 300ms; + animation-duration: 700ms; + animation-fill-mode: both; +} + +/* Back shared: starts immediately, then items enter after */ +::view-transition-group(.nested-shared-post-back) { + animation-duration: 700ms; + animation-timing-function: ease-in-out; +} + +/* Inner shared elements (title, body) start after card begins growing */ +::view-transition-group(.nested-shared-inner-forward) { + animation-duration: 600ms; + animation-delay: 450ms; + animation-timing-function: ease-in-out; + animation-fill-mode: both; +} + +::view-transition-old(.nested-shared-inner-forward) { + animation-delay: 450ms; + animation-duration: 600ms; + animation-fill-mode: both; +} + +::view-transition-new(.nested-shared-inner-forward) { + animation-delay: 450ms; + animation-duration: 600ms; + animation-fill-mode: both; +} + +::view-transition-group(.nested-shared-inner-back) { + animation-duration: 600ms; + animation-delay: 100ms; + animation-timing-function: ease-in-out; + animation-fill-mode: both; +} + +/* Back button slides in from left */ +@keyframes nested-back-btn-enter { + from { + opacity: 0; + translate: -20px 0; + } + to { + opacity: 1; + translate: 0 0; + } +} + +@keyframes nested-back-btn-exit { + from { + opacity: 1; + translate: 0 0; + } + to { + opacity: 0; + translate: -20px 0; + } +} + +::view-transition-new(.nested-back-btn-enter):only-child { + animation: nested-back-btn-enter 300ms ease-out 700ms both; +} + +::view-transition-old(.nested-back-btn-exit):only-child { + animation: nested-back-btn-exit 200ms ease-in forwards; +} + +/* Extra detail content fades in/out */ +@keyframes nested-extra-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +::view-transition-new(.nested-extra-enter):only-child { + animation: nested-extra-enter 300ms ease-out 700ms both; +} + +::view-transition-old(.nested-extra-exit):only-child { + animation: nested-extra-enter 200ms ease-in reverse forwards; +} + +/* Directional enter: items fly back in after shared transition finishes */ +@keyframes nested-enter-from-up { + from { + opacity: 0; + translate: 0 -60px; + } + to { + opacity: 1; + translate: 0 0; + } +} + +@keyframes nested-enter-from-down { + from { + opacity: 0; + translate: 0 60px; + } + to { + opacity: 1; + translate: 0 0; + } +} + +::view-transition-new(.nested-enter-from-up):only-child { + animation: nested-enter-from-up 600ms ease-out 700ms both; +} + +::view-transition-new(.nested-enter-from-down):only-child { + animation: nested-enter-from-down 600ms ease-out 700ms both; +} + +/* Enter animation for detail view (when no shared match) */ +@keyframes nested-enter-detail { + from { + opacity: 0; + translate: 0 30px; + } + to { + opacity: 1; + translate: 0 0; + } +} + +::view-transition-new(.nested-enter-detail):only-child { + animation: nested-enter-detail 600ms ease-out both; +} diff --git a/fixtures/view-transition/src/components/NestedExit.js b/fixtures/view-transition/src/components/NestedExit.js new file mode 100644 index 000000000000..62b939044732 --- /dev/null +++ b/fixtures/view-transition/src/components/NestedExit.js @@ -0,0 +1,132 @@ +import React, {ViewTransition, useState, startTransition, addTransitionType} from 'react'; + +import './NestedExit.css'; + +const items = [ + {id: 1, title: 'First Post', body: 'Hello from the first post.'}, + {id: 2, title: 'Second Post', body: 'Hello from the second post.'}, + {id: 3, title: 'Third Post', body: 'Hello from the third post.'}, +]; + +function FeedItem({item, index, onSelect}) { + // Build exit/enter maps: for each possible clicked item, determine direction + const exitMap = {}; + const enterMap = {}; + items.forEach((_, otherIndex) => { + if (otherIndex !== index) { + const key = 'select-' + otherIndex; + exitMap[key] = + index < otherIndex ? 'nested-exit-up' : 'nested-exit-down'; + enterMap[key] = + index < otherIndex ? 'nested-enter-from-up' : 'nested-enter-from-down'; + } + }); + + const shareInner = { + 'nav-forward': 'nested-shared-inner-forward', + 'nav-back': 'nested-shared-inner-back', + }; + + return ( + +
onSelect(item, index)}> + +

{item.title}

+
+ +

{item.body}

+
+
+
+ ); +} + +function Detail({item, onBack}) { + const shareInner = { + 'nav-forward': 'nested-shared-inner-forward', + 'nav-back': 'nested-shared-inner-back', + }; + + return ( + +
+ + + + +

{item.title}

+
+ +

{item.body}

+
+ +

This is the detail view with more content.

+
+
+
+ ); +} + +export default function NestedExit() { + const [selected, setSelected] = useState(null); + + function selectItem(item, clickedIndex) { + startTransition(() => { + addTransitionType('permalink-navigation'); + addTransitionType('nav-forward'); + addTransitionType('select-' + clickedIndex); + setSelected(item); + }); + } + + function goBack() { + const backIndex = items.findIndex(i => i.id === selected.id); + startTransition(() => { + addTransitionType('permalink-navigation'); + addTransitionType('nav-back'); + addTransitionType('select-' + backIndex); + setSelected(null); + }); + } + + return ( +
+

Nested Exit/Enter

+ + {selected ? ( + + ) : ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 60faa09732d9..9d5e9ffe90ba 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -20,6 +20,7 @@ import './Page.css'; import transitions from './Transitions.module.css'; import NestedReveal from './NestedReveal.js'; +import NestedExit from './NestedExit.js'; async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -322,6 +323,7 @@ export default function Page({url, navigate}) { + ); } diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index 6d6281102371..657c0fa8f546 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -5,8 +5,17 @@ import React, { unstable_startGestureTransition as startGestureTransition, } from 'react'; -import ScrollTimelinePolyfill from 'animation-timelines/scroll-timeline'; -import TouchPanTimeline from 'animation-timelines/touch-pan-timeline'; +// These are ESM-only packages. We use a conditional require to avoid +// require() of ESM errors during SSR with @babel/register. +// On the client, webpack handles the bundling and supports require of ESM. +let ScrollTimelinePolyfill; +let TouchPanTimeline; +if (typeof document !== 'undefined') { + ScrollTimelinePolyfill = + require('animation-timelines/scroll-timeline').default; + TouchPanTimeline = + require('animation-timelines/touch-pan-timeline').default; +} const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent; const isSafariMobile = From ed9e0905f61dc492f88e07fa555ab40e9d9dd865 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 24 Mar 2026 15:52:04 -0400 Subject: [PATCH 4/5] prettier --- .../view-transition/src/components/NestedExit.js | 10 +++++++--- .../src/components/SwipeRecognizer.js | 3 +-- .../src/__tests__/ReactDOMViewTransition-test.js | 12 ++++-------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/fixtures/view-transition/src/components/NestedExit.js b/fixtures/view-transition/src/components/NestedExit.js index 62b939044732..acf6ddbecaa6 100644 --- a/fixtures/view-transition/src/components/NestedExit.js +++ b/fixtures/view-transition/src/components/NestedExit.js @@ -1,4 +1,9 @@ -import React, {ViewTransition, useState, startTransition, addTransitionType} from 'react'; +import React, { + ViewTransition, + useState, + startTransition, + addTransitionType, +} from 'react'; import './NestedExit.css'; @@ -15,8 +20,7 @@ function FeedItem({item, index, onSelect}) { items.forEach((_, otherIndex) => { if (otherIndex !== index) { const key = 'select-' + otherIndex; - exitMap[key] = - index < otherIndex ? 'nested-exit-up' : 'nested-exit-down'; + exitMap[key] = index < otherIndex ? 'nested-exit-up' : 'nested-exit-down'; enterMap[key] = index < otherIndex ? 'nested-enter-from-up' : 'nested-enter-from-down'; } diff --git a/fixtures/view-transition/src/components/SwipeRecognizer.js b/fixtures/view-transition/src/components/SwipeRecognizer.js index 657c0fa8f546..4c284b4b79ba 100644 --- a/fixtures/view-transition/src/components/SwipeRecognizer.js +++ b/fixtures/view-transition/src/components/SwipeRecognizer.js @@ -13,8 +13,7 @@ let TouchPanTimeline; if (typeof document !== 'undefined') { ScrollTimelinePolyfill = require('animation-timelines/scroll-timeline').default; - TouchPanTimeline = - require('animation-timelines/touch-pan-timeline').default; + TouchPanTimeline = require('animation-timelines/touch-pan-timeline').default; } const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent; diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js index f3b5dd1a21dc..f7ac6ca799c0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js @@ -540,9 +540,7 @@ describe('ReactDOMViewTransition', () => { return (
- +
Item 1
@@ -555,9 +553,7 @@ describe('ReactDOMViewTransition', () => { return (
- +
Detail Item
@@ -618,12 +614,12 @@ describe('ReactDOMViewTransition', () => {
Item 1
Item 2
From 3b017d04bfcfe4dfbf5960dbff233726dc045d91 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 25 Mar 2026 10:01:26 -0400 Subject: [PATCH 5/5] Include ViewTransitionNamedStatic in subtreeFlags check for nested enter transitions The commitEnterViewTransitions walk was only checking for ViewTransitionStatic in subtreeFlags, causing it to skip subtrees that only contained ViewTransitionNamedStatic children. This meant nested named view transitions would not fire their enter animations. --- .../react-reconciler/src/ReactFiberCommitViewTransitions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 207d52ec2e15..a42daf63bea8 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -410,7 +410,7 @@ export function commitEnterViewTransitions( if (enableViewTransitionNested) { commitNestedEnterViewTransitions(placement, gesture); } - } else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) { + } else if ((placement.subtreeFlags & (ViewTransitionStatic | ViewTransitionNamedStatic)) !== NoFlags) { let child = placement.child; while (child !== null) { commitEnterViewTransitions(child, gesture);