diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js index 1c5b43a18acd..3ef2c1bf49fa 100644 --- a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js @@ -16,8 +16,17 @@ let SuspenseList; let ViewTransition; let act; let assertLog; +let assertConsoleWarnDev; let Scheduler; let textCache; +let finishMockViewTransition; +let originalStartViewTransition; +let originalGetBoundingClientRect; +let originalCSS; +let originalGetAnimations; +let originalInnerWidth; +let originalInnerHeight; +let originalFonts; describe('ReactDOMViewTransition', () => { let container; @@ -29,6 +38,7 @@ describe('ReactDOMViewTransition', () => { Scheduler = require('scheduler'); act = require('internal-test-utils').act; assertLog = require('internal-test-utils').assertLog; + assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev; Suspense = React.Suspense; ViewTransition = React.ViewTransition; if (gate(flags => flags.enableSuspenseList)) { @@ -38,9 +48,79 @@ describe('ReactDOMViewTransition', () => { document.body.appendChild(container); textCache = new Map(); + + finishMockViewTransition = null; + originalStartViewTransition = document.startViewTransition; + originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + originalCSS = global.CSS; + originalGetAnimations = document.documentElement.getAnimations; + originalInnerWidth = window.innerWidth; + originalInnerHeight = window.innerHeight; + originalFonts = document.fonts; + if (originalCSS == null || typeof originalCSS.escape !== 'function') { + global.CSS = { + escape: value => value, + }; + } + document.documentElement.getAnimations = function () { + return []; + }; + document.fonts = { + status: 'loaded', + ready: Promise.resolve(), + }; + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1024, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 768, + }); + // Force visibility checks to pass in jsdom so transition callbacks run. + HTMLElement.prototype.getBoundingClientRect = function () { + return { + top: 1, + left: 1, + right: 11, + bottom: 11, + width: 10, + height: 10, + x: 1, + y: 1, + toJSON() {}, + }; + }; + document.startViewTransition = jest.fn(({update}) => { + const ready = Promise.resolve().then(() => update()); + return { + skipTransition() {}, + ready, + finished: new Promise(resolve => { + finishMockViewTransition = resolve; + }), + }; + }); }); afterEach(() => { + if (finishMockViewTransition !== null) { + finishMockViewTransition(); + finishMockViewTransition = null; + } + document.startViewTransition = originalStartViewTransition; + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + global.CSS = originalCSS; + document.documentElement.getAnimations = originalGetAnimations; + document.fonts = originalFonts; + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalInnerHeight, + }); document.body.removeChild(container); }); @@ -176,4 +256,50 @@ describe('ReactDOMViewTransition', () => { expect(container.textContent).toContain('Card 2'); expect(container.textContent).toContain('Card 3'); }); + + // @gate enableViewTransition + it('calls onExit cleanup immediately on unmount', async () => { + const log = []; + + function App({show}) { + return show ? ( + { + log.push('exit'); + return () => { + log.push('cleanup'); + }; + }}> +
A
+
+ ) : null; + } + + const root = ReactDOMClient.createRoot(container); + await act(async () => { + React.startTransition(() => { + root.render(); + }); + }); + document.startViewTransition.mockClear(); + + log.length = 0; + await act(async () => { + React.startTransition(() => { + root.render(); + }); + }); + if (__DEV__) { + assertConsoleWarnDev([ + 'A flushSync update cancelled a View Transition because it was called ' + + 'while the View Transition was still preparing. To preserve the synchronous ' + + "semantics, React had to skip the View Transition. If you can, try to avoid " + + "flushSync() in a scenario that's likely to interfere.", + ]); + } + expect(document.startViewTransition).toHaveBeenCalledTimes(1); + + expect(log).toEqual(['exit', 'cleanup']); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index a9edc0c84d23..d2bc60138dfa 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -446,7 +446,7 @@ export function commitExitViewTransitions(deletion: Fiber): void { // Therefore it's possible for onShare to be called with only an old snapshot. scheduleViewTransitionEvent(deletion, props.onShare); } else { - scheduleViewTransitionEvent(deletion, props.onExit); + scheduleViewTransitionEvent(deletion, props.onExit, true); } } if (appearingViewTransitions !== null) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index d055b271ad77..78622c4537e5 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -735,9 +735,13 @@ let pendingEffectsRenderEndTime: number = -0; // Profiling-only let pendingPassiveTransitions: Array | null = null; let pendingRecoverableErrors: null | Array> = null; let pendingViewTransition: null | RunningViewTransition = null; -let pendingViewTransitionEvents: Array< - (types: Array) => void | (() => void), -> | null = null; +type PendingViewTransitionEvent = { + callback: (types: Array) => void | (() => void), + // If true, run the cleanup immediately after unmount. + cleanupOnUnmount: boolean, +}; +let pendingViewTransitionEvents: Array | null = + null; let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = null; // Profiling-only @@ -906,6 +910,7 @@ export function scheduleViewTransitionEvent( instance: ViewTransitionInstance, types: Array, ) => void | (() => void), + cleanupOnUnmount?: boolean, ): void { if (enableViewTransition) { if (callback != null) { @@ -919,7 +924,10 @@ export function scheduleViewTransitionEvent( if (pendingViewTransitionEvents === null) { pendingViewTransitionEvents = []; } - pendingViewTransitionEvents.push(callback.bind(null, instance)); + pendingViewTransitionEvents.push({ + callback: callback.bind(null, instance), + cleanupOnUnmount: cleanupOnUnmount === true, + }); } } } @@ -952,9 +960,10 @@ export function scheduleGestureTransitionEvent( if (pendingViewTransitionEvents === null) { pendingViewTransitionEvents = []; } - pendingViewTransitionEvents.push( - callback.bind(null, timeline, options, instance), - ); + pendingViewTransitionEvents.push({ + callback: callback.bind(null, timeline, options, instance), + cleanupOnUnmount: false, + }); } } } @@ -4276,10 +4285,18 @@ function flushSpawnedWork(): void { } if (committedViewTransition !== null) { for (let i = 0; i < pendingEvents.length; i++) { - const viewTransitionEvent = pendingEvents[i]; + const {callback: viewTransitionEvent, cleanupOnUnmount} = + pendingEvents[i]; const cleanup = viewTransitionEvent(pendingTypes); if (cleanup !== undefined) { - addViewTransitionFinishedListener(committedViewTransition, cleanup); + if (cleanupOnUnmount) { + cleanup(); + } else { + addViewTransitionFinishedListener( + committedViewTransition, + cleanup, + ); + } } } } @@ -4554,10 +4571,15 @@ function flushGestureAnimations(): void { const runningTransition = appliedGesture.running; if (runningTransition !== null) { for (let i = 0; i < pendingEvents.length; i++) { - const viewTransitionEvent = pendingEvents[i]; + const {callback: viewTransitionEvent, cleanupOnUnmount} = + pendingEvents[i]; const cleanup = viewTransitionEvent(pendingTypes); if (cleanup !== undefined) { - addViewTransitionFinishedListener(runningTransition, cleanup); + if (cleanupOnUnmount) { + cleanup(); + } else { + addViewTransitionFinishedListener(runningTransition, cleanup); + } } } }