From 9aca6e7a403d8b9664117c026efa84b688fcc862 Mon Sep 17 00:00:00 2001 From: vansszh Date: Thu, 21 May 2026 15:55:07 +0530 Subject: [PATCH] Add comprehensive ViewTransition component unit tests Add three new test files covering ViewTransition component behavior: - ReactDOMViewTransitionName-test.js: Tests for name resolution (auto-generated names, explicit names, name stability across re-renders, sibling and nested ViewTransitions, conditional rendering, mount/unmount cycles) - ReactDOMViewTransitionClass-test.js: Tests for className resolution (string classes, object classes with transition types, 'none' precedence, 'auto' behavior, multiple matching types, default fallback, event class overrides) - ReactDOMViewTransitionWarnings-test.js: Tests for duplicate name warnings and edge cases (duplicate name detection, 'auto' name exemption, unnamed exemption, unmount-before-mount, warn-once behavior, rapid mount/unmount, list operations, Suspense integration, key changes, deeply nested ViewTransitions, Fragment/memo/forwardRef children) The existing test coverage for ViewTransition is minimal (6 tests in ReactDOMViewTransition-test.js) relative to the 35KB+ of source code in ReactFiberCommitViewTransitions.js and ReactFiberViewTransitionComponent.js. These tests improve coverage of the component's core behaviors. --- .../ReactDOMViewTransitionClass-test.js | 529 ++++++++++++ .../ReactDOMViewTransitionName-test.js | 320 +++++++ .../ReactDOMViewTransitionWarnings-test.js | 799 ++++++++++++++++++ 3 files changed, 1648 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactDOMViewTransitionClass-test.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMViewTransitionName-test.js create mode 100644 packages/react-dom/src/__tests__/ReactDOMViewTransitionWarnings-test.js diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransitionClass-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransitionClass-test.js new file mode 100644 index 000000000000..236cf84c1d7e --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransitionClass-test.js @@ -0,0 +1,529 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMClient; +let ViewTransition; +let act; +let startTransition; +let addTransitionType; +let container; + +describe('ReactDOMViewTransitionClass', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + ViewTransition = React.ViewTransition; + startTransition = React.startTransition; + addTransitionType = React.addTransitionType; + container = document.createElement('div'); + document.body.appendChild(container); + + // Mock document.startViewTransition + if (!document.startViewTransition) { + document.startViewTransition = function ({update}) { + update(); + return { + ready: Promise.resolve(), + finished: Promise.resolve(), + skipTransition() {}, + }; + }; + } + + // Mock CSS.escape + 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 Element.prototype.getAnimations + if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = function () { + return []; + }; + } + + // Mock Element.prototype.animate + if (!Element.prototype.animate) { + Element.prototype.animate = function () { + return {cancel() {}, finished: Promise.resolve()}; + }; + } + + // Mock getBoundingClientRect + Element.prototype._originalGetBoundingClientRect = + Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const text = this.textContent || ''; + return new DOMRect(0, 0, text.length * 10 + 10, 20); + }; + }); + + afterEach(() => { + document.body.removeChild(container); + if (Element.prototype._originalGetBoundingClientRect) { + Element.prototype.getBoundingClientRect = + Element.prototype._originalGetBoundingClientRect; + delete Element.prototype._originalGetBoundingClientRect; + } + }); + + // @gate enableViewTransition + it('accepts a string as the default class', async () => { + function App() { + return ( + +
Hello
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child')).not.toBe(null); + }); + + // @gate enableViewTransition + it('accepts a string as the update class', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('accepts a string as the enter class', async () => { + function App({show}) { + if (!show) return null; + return ( + +
Entered
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Entered'); + }); + + // @gate enableViewTransition + it('accepts a string as the exit class', async () => { + function App({show}) { + if (!show) return null; + return ( + +
Will Exit
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Will Exit'); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child')).toBe(null); + }); + + // @gate enableViewTransition + it('accepts a string as the share class', async () => { + function App({page}) { + if (page === 'a') { + return ( + +
Page A
+
+ ); + } + return ( + +
Page B
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-a')).not.toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-b')).not.toBe(null); + }); + + // @gate enableViewTransition + it('accepts an object with default key as the class', async () => { + function App() { + return ( + +
Object class
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child')).not.toBe(null); + }); + + // @gate enableViewTransition + it('accepts an object with transition type keys', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + await act(() => { + startTransition(() => { + addTransitionType('nav-forward'); + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('handles "none" class which suppresses the transition', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('handles "none" in object class which takes precedence', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // When nav-forward type is active, "none" should take precedence + await act(() => { + startTransition(() => { + addTransitionType('nav-forward'); + addTransitionType('nav-back'); + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('handles "auto" class which means use default behavior', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('combines multiple matching transition type classes', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // When multiple types match, their classes should be combined + await act(() => { + startTransition(() => { + addTransitionType('highlight'); + addTransitionType('important'); + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('falls back to default when no transition types match', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // No transition type added, should fall back to default + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('event class overrides default class', async () => { + function App({show}) { + if (!show) return null; + return ( + +
Content
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Content'); + }); + + // @gate enableViewTransition + it('event class "auto" nullifies the class', async () => { + function App({show}) { + if (!show) return null; + return ( + +
Content
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#child').textContent).toBe('Content'); + }); + + // @gate enableViewTransition + it('supports different classes for enter, exit, update, and share', async () => { + function PageA() { + return ( + +
Page A Content
+
+ ); + } + + function PageB() { + return ( + +
Page B Content
+
+ ); + } + + function App({page}) { + return page === 'a' ? : ; + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-a')).not.toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-b')).not.toBe(null); + expect(container.querySelector('#page-a')).toBe(null); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransitionName-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransitionName-test.js new file mode 100644 index 000000000000..7bba8e849bef --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransitionName-test.js @@ -0,0 +1,320 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMClient; +let ViewTransition; +let act; +let startTransition; +let addTransitionType; +let container; + +describe('ReactDOMViewTransitionName', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + ViewTransition = React.ViewTransition; + startTransition = React.startTransition; + addTransitionType = React.addTransitionType; + container = document.createElement('div'); + document.body.appendChild(container); + + // Mock document.startViewTransition + if (!document.startViewTransition) { + document.startViewTransition = function ({update}) { + update(); + return { + ready: Promise.resolve(), + finished: Promise.resolve(), + skipTransition() {}, + }; + }; + } + + // Mock CSS.escape + 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 Element.prototype.getAnimations + if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = function () { + return []; + }; + } + + // Mock Element.prototype.animate + if (!Element.prototype.animate) { + Element.prototype.animate = function () { + return {cancel() {}, finished: Promise.resolve()}; + }; + } + + // Mock getBoundingClientRect + Element.prototype._originalGetBoundingClientRect = + Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const text = this.textContent || ''; + return new DOMRect(0, 0, text.length * 10 + 10, 20); + }; + }); + + afterEach(() => { + document.body.removeChild(container); + if (Element.prototype._originalGetBoundingClientRect) { + Element.prototype.getBoundingClientRect = + Element.prototype._originalGetBoundingClientRect; + delete Element.prototype._originalGetBoundingClientRect; + } + }); + + // @gate enableViewTransition + it('renders children without a wrapper element', async () => { + function App() { + return ( + +
Hello
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#child')).not.toBe(null); + expect(container.querySelector('#child').textContent).toBe('Hello'); + // ViewTransition should not add a wrapper element + expect(container.firstChild.id).toBe('child'); + }); + + // @gate enableViewTransition + it('uses explicit name prop when provided', async () => { + function App() { + return ( + +
Named
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // The element should be rendered + expect(container.querySelector('#child')).not.toBe(null); + }); + + // @gate enableViewTransition + it('generates auto name when name is not provided', async () => { + function App() { + return ( + +
Auto
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#auto-named')).not.toBe(null); + }); + + // @gate enableViewTransition + it('generates auto name when name is "auto"', async () => { + function App() { + return ( + +
Auto Explicit
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#auto-explicit')).not.toBe(null); + }); + + // @gate enableViewTransition + it('preserves auto name across re-renders', async () => { + function App({text}) { + return ( + +
{text}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#stable').textContent).toBe('First'); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#stable').textContent).toBe('Second'); + }); + + // @gate enableViewTransition + it('supports multiple ViewTransitions as siblings', async () => { + function App() { + return ( +
+ +
First
+
+ +
Second
+
+ +
Third
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#first').textContent).toBe('First'); + expect(container.querySelector('#second').textContent).toBe('Second'); + expect(container.querySelector('#third').textContent).toBe('Third'); + }); + + // @gate enableViewTransition + it('supports nested ViewTransitions', async () => { + function App() { + return ( + +
+ + Nested + +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#outer')).not.toBe(null); + expect(container.querySelector('#inner').textContent).toBe('Nested'); + }); + + // @gate enableViewTransition + it('handles conditional rendering inside ViewTransition', async () => { + function App({show}) { + return ( + +
{show ? Visible : null}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#wrapper').textContent).toBe(''); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#wrapper').textContent).toBe('Visible'); + }); + + // @gate enableViewTransition + it('handles ViewTransition mounting and unmounting', async () => { + function App({show}) { + if (!show) { + return
Empty
; + } + return ( + +
Content
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#empty')).not.toBe(null); + expect(container.querySelector('#content')).toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#content')).not.toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#content')).toBe(null); + expect(container.querySelector('#empty')).not.toBe(null); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransitionWarnings-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransitionWarnings-test.js new file mode 100644 index 000000000000..f79f35e31737 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransitionWarnings-test.js @@ -0,0 +1,799 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMClient; +let ViewTransition; +let Suspense; +let act; +let startTransition; +let addTransitionType; +let container; + +describe('ReactDOMViewTransitionWarnings', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + ViewTransition = React.ViewTransition; + Suspense = React.Suspense; + startTransition = React.startTransition; + addTransitionType = React.addTransitionType; + container = document.createElement('div'); + document.body.appendChild(container); + + // Mock document.startViewTransition + if (!document.startViewTransition) { + document.startViewTransition = function ({update}) { + update(); + return { + ready: Promise.resolve(), + finished: Promise.resolve(), + skipTransition() {}, + }; + }; + } + + // Mock CSS.escape + 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 Element.prototype.getAnimations + if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = function () { + return []; + }; + } + + // Mock Element.prototype.animate + if (!Element.prototype.animate) { + Element.prototype.animate = function () { + return {cancel() {}, finished: Promise.resolve()}; + }; + } + + // Mock getBoundingClientRect + Element.prototype._originalGetBoundingClientRect = + Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const text = this.textContent || ''; + return new DOMRect(0, 0, text.length * 10 + 10, 20); + }; + }); + + afterEach(() => { + document.body.removeChild(container); + if (Element.prototype._originalGetBoundingClientRect) { + Element.prototype.getBoundingClientRect = + Element.prototype._originalGetBoundingClientRect; + delete Element.prototype._originalGetBoundingClientRect; + } + }); + + // @gate enableViewTransition && __DEV__ + it('warns when two ViewTransitions have the same name', async () => { + function App() { + return ( +
+ +
First
+
+ +
Second
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + startTransition(() => { + root.render(); + }); + }); + }).toErrorDev([ + 'There are two components with the same name', + 'The existing duplicate has this stack trace', + ]); + }); + + // @gate enableViewTransition && __DEV__ + it('does not warn for "auto" name duplicates', async () => { + function App() { + return ( +
+ +
First
+
+ +
Second
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + // Should not produce any warnings + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#first')).not.toBe(null); + expect(container.querySelector('#second')).not.toBe(null); + }); + + // @gate enableViewTransition && __DEV__ + it('does not warn for unnamed ViewTransitions', async () => { + function App() { + return ( +
+ +
First
+
+ +
Second
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + // Should not produce any warnings + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#first')).not.toBe(null); + expect(container.querySelector('#second')).not.toBe(null); + }); + + // @gate enableViewTransition && __DEV__ + it('does not warn when duplicate is unmounted before new one mounts', async () => { + function App({page}) { + if (page === 'a') { + return ( + +
Page A
+
+ ); + } + return ( + +
Page B
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-a')).not.toBe(null); + + // Switching pages should not warn because old one unmounts + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#page-b')).not.toBe(null); + }); + + // @gate enableViewTransition && __DEV__ + it('only warns once per duplicate name', async () => { + function App({count}) { + const items = []; + for (let i = 0; i < count; i++) { + items.push( + +
{i}
+
, + ); + } + return
{items}
; + } + + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + startTransition(() => { + root.render(); + }); + }); + }).toErrorDev([ + 'There are two components with the same name', + 'The existing duplicate has this stack trace', + ]); + + // Adding more duplicates should not warn again + await act(() => { + startTransition(() => { + root.render(); + }); + }); + }); + + // @gate enableViewTransition + it('supports different unique names without warnings', async () => { + function App() { + return ( +
+ + + + +
Main
+
+ +
Footer
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#header').textContent).toBe('Header'); + expect(container.querySelector('#main').textContent).toBe('Main'); + expect(container.querySelector('#footer').textContent).toBe('Footer'); + }); +}); + +describe('ReactDOMViewTransitionEdgeCases', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + ViewTransition = React.ViewTransition; + Suspense = React.Suspense; + startTransition = React.startTransition; + addTransitionType = React.addTransitionType; + container = document.createElement('div'); + document.body.appendChild(container); + + // Mock document.startViewTransition + if (!document.startViewTransition) { + document.startViewTransition = function ({update}) { + update(); + return { + ready: Promise.resolve(), + finished: Promise.resolve(), + skipTransition() {}, + }; + }; + } + + // Mock CSS.escape + 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 Element.prototype.getAnimations + if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = function () { + return []; + }; + } + + // Mock Element.prototype.animate + if (!Element.prototype.animate) { + Element.prototype.animate = function () { + return {cancel() {}, finished: Promise.resolve()}; + }; + } + + // Mock getBoundingClientRect + Element.prototype._originalGetBoundingClientRect = + Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const text = this.textContent || ''; + return new DOMRect(0, 0, text.length * 10 + 10, 20); + }; + }); + + afterEach(() => { + document.body.removeChild(container); + if (Element.prototype._originalGetBoundingClientRect) { + Element.prototype.getBoundingClientRect = + Element.prototype._originalGetBoundingClientRect; + delete Element.prototype._originalGetBoundingClientRect; + } + }); + + // @gate enableViewTransition + it('handles rapid mount/unmount cycles', async () => { + function App({show}) { + if (!show) return null; + return ( + +
Rapid
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Rapid mount/unmount + await act(() => { + startTransition(() => { + root.render(); + }); + }); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#rapid')).not.toBe(null); + }); + + // @gate enableViewTransition + it('handles ViewTransition with null children', async () => { + function App() { + return ( + +
{null}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#wrapper')).not.toBe(null); + expect(container.querySelector('#wrapper').textContent).toBe(''); + }); + + // @gate enableViewTransition + it('handles ViewTransition with text children', async () => { + function App() { + return ( + +
Just text content
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#text-child').textContent).toBe( + 'Just text content', + ); + }); + + // @gate enableViewTransition + it('handles ViewTransition with multiple DOM children', async () => { + function App() { + return ( + +
+ One + Two + Three +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + const parent = container.querySelector('#multi-parent'); + expect(parent.children.length).toBe(3); + expect(parent.children[0].textContent).toBe('One'); + expect(parent.children[1].textContent).toBe('Two'); + expect(parent.children[2].textContent).toBe('Three'); + }); + + // @gate enableViewTransition + it('handles ViewTransition inside a list', async () => { + function App({items}) { + return ( +
    + {items.map(item => ( + +
  • {item.text}
  • +
    + ))} +
+ ); + } + + const items = [ + {id: 1, text: 'Apple'}, + {id: 2, text: 'Banana'}, + {id: 3, text: 'Cherry'}, + ]; + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#item-1').textContent).toBe('Apple'); + expect(container.querySelector('#item-2').textContent).toBe('Banana'); + expect(container.querySelector('#item-3').textContent).toBe('Cherry'); + + // Reorder items + const reordered = [items[2], items[0], items[1]]; + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + const listItems = container.querySelectorAll('li'); + expect(listItems[0].textContent).toBe('Cherry'); + expect(listItems[1].textContent).toBe('Apple'); + expect(listItems[2].textContent).toBe('Banana'); + }); + + // @gate enableViewTransition + it('handles adding items to a list with ViewTransitions', async () => { + function App({items}) { + return ( +
    + {items.map(item => ( + +
  • {item}
  • +
    + ))} +
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelectorAll('li').length).toBe(2); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelectorAll('li').length).toBe(4); + expect(container.querySelector('#list-c').textContent).toBe('c'); + expect(container.querySelector('#list-d').textContent).toBe('d'); + }); + + // @gate enableViewTransition + it('handles removing items from a list with ViewTransitions', async () => { + function App({items}) { + return ( +
    + {items.map(item => ( + +
  • {item}
  • +
    + ))} +
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelectorAll('li').length).toBe(3); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelectorAll('li').length).toBe(1); + expect(container.querySelector('#rm-x').textContent).toBe('x'); + expect(container.querySelector('#rm-y')).toBe(null); + expect(container.querySelector('#rm-z')).toBe(null); + }); + + // @gate enableViewTransition + it('handles ViewTransition with Suspense fallback', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + + function AsyncChild() { + return React.use(promise); + } + + function App() { + return ( + + Loading...}> + + + + ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + // Should show fallback + expect(container.querySelector('#fallback')).not.toBe(null); + + // Resolve the promise + await act(async () => { + resolve(
Loaded!
); + }); + + expect(container.querySelector('#resolved')).not.toBe(null); + expect(container.querySelector('#fallback')).toBe(null); + }); + + // @gate enableViewTransition + it('handles ViewTransition with key change', async () => { + function App({id}) { + return ( + +
Card {id}
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#card-1')).not.toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#card-2')).not.toBe(null); + expect(container.querySelector('#card-1')).toBe(null); + }); + + // @gate enableViewTransition + it('handles ViewTransition name change on same component', async () => { + function App({name}) { + return ( + +
Named transition
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#named')).not.toBe(null); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#named')).not.toBe(null); + }); + + // @gate enableViewTransition + it('handles deeply nested ViewTransitions', async () => { + function App() { + return ( + +
+ +
+ +
Deep
+
+
+
+
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#l1')).not.toBe(null); + expect(container.querySelector('#l2')).not.toBe(null); + expect(container.querySelector('#l3').textContent).toBe('Deep'); + }); + + // @gate enableViewTransition + it('handles ViewTransition with Fragment children', async () => { + function App() { + return ( + +
+ <> + Fragment child 1 + Fragment child 2 + +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + const parent = container.querySelector('#frag-parent'); + expect(parent.children.length).toBe(2); + expect(parent.children[0].textContent).toBe('Fragment child 1'); + expect(parent.children[1].textContent).toBe('Fragment child 2'); + }); + + // @gate enableViewTransition + it('handles ViewTransition with component children', async () => { + function Child({text}) { + return {text}; + } + + function App() { + return ( + +
+ +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#comp-child').textContent).toBe( + 'Component child', + ); + }); + + // @gate enableViewTransition + it('handles ViewTransition with memo component', async () => { + const MemoChild = React.memo(function MemoChild({text}) { + return
{text}
; + }); + + function App({text}) { + return ( + +
+ +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#memo-child').textContent).toBe( + 'Memoized', + ); + + await act(() => { + startTransition(() => { + root.render(); + }); + }); + + expect(container.querySelector('#memo-child').textContent).toBe('Updated'); + }); + + // @gate enableViewTransition + it('handles ViewTransition with forwardRef component', async () => { + const RefChild = React.forwardRef(function RefChild(props, ref) { + return ( +
+ {props.text} +
+ ); + }); + + function App() { + const ref = React.useRef(null); + return ( + +
+ +
+
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + expect(container.querySelector('#ref-child').textContent).toBe('With ref'); + }); +});