From f6790c1068234252ecb65b515f170dca825cdcad Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Thu, 14 May 2026 08:34:59 -0400 Subject: [PATCH] Add ViewTransition event callback unit tests (#36467) Pulling some basic test coverage out of https://github.com/facebook/react/pull/36135 as these unit tests are needed regardless of nested enter/exit work. --- .../__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..a44b3f5235ee 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 textCache; +let startTransition; describe('ReactDOMViewTransition', () => { let container; @@ -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); + }); + }); });