From 9b0cd8437388a0880f239fc8d3f7545b0ca0b22b Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Mon, 23 Mar 2026 15:36:18 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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