diff --git a/packages/react-dom/src/__tests__/ReactTestUtils-test.js b/packages/react-dom/src/__tests__/ReactTestUtils-test.js index fcc332737ebcb..3bd2b5d59aa83 100644 --- a/packages/react-dom/src/__tests__/ReactTestUtils-test.js +++ b/packages/react-dom/src/__tests__/ReactTestUtils-test.js @@ -9,24 +9,543 @@ 'use strict'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as ReactDOMServer from 'react-dom/server'; import * as ReactTestUtils from 'react-dom/test-utils'; +function getTestDocument(markup) { + const doc = document.implementation.createHTMLDocument(''); + doc.open(); + doc.write( + markup || + 'test doc', + ); + doc.close(); + return doc; +} + describe('ReactTestUtils', () => { - it('contains act', async () => { - expect(typeof ReactTestUtils.act).toBe('function'); + it('Simulate should have locally attached media events', () => { + expect(Object.keys(ReactTestUtils.Simulate).sort()).toMatchSnapshot(); + }); + + // @gate !disableDOMTestUtils + it('gives Jest mocks a passthrough implementation with mockComponent()', () => { + class MockedComponent extends React.Component { + render() { + throw new Error('Should not get here.'); + } + } + // This is close enough to what a Jest mock would give us. + MockedComponent.prototype.render = jest.fn(); + + // Patch it up so it returns its children. + expect(() => ReactTestUtils.mockComponent(MockedComponent)).toWarnDev( + 'ReactTestUtils.mockComponent() is deprecated. ' + + 'Use shallow rendering or jest.mock() instead.\n\n' + + 'See https://react.dev/link/test-utils-mock-component for more information.', + {withoutStack: true}, + ); + + // De-duplication check + ReactTestUtils.mockComponent(MockedComponent); + + const container = document.createElement('div'); + ReactDOM.render(Hello, container); + expect(container.textContent).toBe('Hello'); + }); + + // @gate !disableDOMTestUtils + it('can scryRenderedComponentsWithType', () => { + class Child extends React.Component { + render() { + return null; + } + } + class Wrapper extends React.Component { + render() { + return ( +
+ +
+ ); + } + } + const renderedComponent = ReactTestUtils.renderIntoDocument(); + const scryResults = ReactTestUtils.scryRenderedComponentsWithType( + renderedComponent, + Child, + ); + expect(scryResults.length).toBe(1); + }); + + // @gate !disableDOMTestUtils + it('can scryRenderedDOMComponentsWithClass with TextComponent', () => { + class Wrapper extends React.Component { + render() { + return ( +
+ Hello Jim +
+ ); + } + } + + const renderedComponent = ReactTestUtils.renderIntoDocument(); + const scryResults = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + 'NonExistentClass', + ); + expect(scryResults.length).toBe(0); + }); + + // @gate !disableDOMTestUtils + it('can scryRenderedDOMComponentsWithClass with className contains \\n', () => { + class Wrapper extends React.Component { + render() { + return ( +
+ Hello Jim +
+ ); + } + } + + const renderedComponent = ReactTestUtils.renderIntoDocument(); + const scryResults = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + 'x', + ); + expect(scryResults.length).toBe(1); + }); + + // @gate !disableDOMTestUtils + it('can scryRenderedDOMComponentsWithClass with multiple classes', () => { + class Wrapper extends React.Component { + render() { + return ( +
+ Hello Jim +
+ ); + } + } + + const renderedComponent = ReactTestUtils.renderIntoDocument(); + const scryResults1 = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + 'x y', + ); + expect(scryResults1.length).toBe(1); + + const scryResults2 = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + 'x z', + ); + expect(scryResults2.length).toBe(1); + + const scryResults3 = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + ['x', 'y'], + ); + expect(scryResults3.length).toBe(1); + + expect(scryResults1[0]).toBe(scryResults2[0]); + expect(scryResults1[0]).toBe(scryResults3[0]); + + const scryResults4 = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + ['x', 'a'], + ); + expect(scryResults4.length).toBe(0); + + const scryResults5 = ReactTestUtils.scryRenderedDOMComponentsWithClass( + renderedComponent, + ['x a'], + ); + expect(scryResults5.length).toBe(0); + }); + + // @gate !disableDOMTestUtils + it('traverses children in the correct order', () => { + class Wrapper extends React.Component { + render() { + return
{this.props.children}
; + } + } + + const container = document.createElement('div'); + ReactDOM.render( + + {null} +
purple
+
, + container, + ); + const tree = ReactDOM.render( + +
orange
+
purple
+
, + container, + ); + + const log = []; + ReactTestUtils.findAllInRenderedTree(tree, function (child) { + if (ReactTestUtils.isDOMComponent(child)) { + log.push(ReactDOM.findDOMNode(child).textContent); + } + }); + + // Should be document order, not mount order (which would be purple, orange) + expect(log).toEqual(['orangepurple', 'orange', 'purple']); + }); + + // @gate !disableDOMTestUtils + it('should support injected wrapper components as DOM components', () => { + const injectedDOMComponents = [ + 'button', + 'form', + 'iframe', + 'img', + 'input', + 'option', + 'select', + 'textarea', + ]; + + injectedDOMComponents.forEach(function (type) { + const testComponent = ReactTestUtils.renderIntoDocument( + React.createElement(type), + ); + expect(testComponent.tagName).toBe(type.toUpperCase()); + expect(ReactTestUtils.isDOMComponent(testComponent)).toBe(true); + }); + + // Full-page components (html, head, body) can't be rendered into a div + // directly... + class Root extends React.Component { + htmlRef = React.createRef(); + headRef = React.createRef(); + bodyRef = React.createRef(); + + render() { + return ( + + + hello + + hello, world + + ); + } + } + + const markup = ReactDOMServer.renderToString(); + const testDocument = getTestDocument(markup); + const component = ReactDOM.hydrate(, testDocument); + + expect(component.htmlRef.current.tagName).toBe('HTML'); + expect(component.headRef.current.tagName).toBe('HEAD'); + expect(component.bodyRef.current.tagName).toBe('BODY'); + expect(ReactTestUtils.isDOMComponent(component.htmlRef.current)).toBe(true); + expect(ReactTestUtils.isDOMComponent(component.headRef.current)).toBe(true); + expect(ReactTestUtils.isDOMComponent(component.bodyRef.current)).toBe(true); + }); + + // @gate !disableDOMTestUtils + it('can scry with stateless components involved', () => { + const Function = () => ( +
+
+
+ ); + + class SomeComponent extends React.Component { + render() { + return ( +
+ +
+
+ ); + } + } + + const inst = ReactTestUtils.renderIntoDocument(); + const hrs = ReactTestUtils.scryRenderedDOMComponentsWithTag(inst, 'hr'); + expect(hrs.length).toBe(2); + }); + + // @gate !disableDOMTestUtils + it('provides a clear error when passing invalid objects to scry', () => { + // This is probably too relaxed but it's existing behavior. + ReactTestUtils.findAllInRenderedTree(null, 'span'); + ReactTestUtils.findAllInRenderedTree(undefined, 'span'); + ReactTestUtils.findAllInRenderedTree('', 'span'); + ReactTestUtils.findAllInRenderedTree(0, 'span'); + ReactTestUtils.findAllInRenderedTree(false, 'span'); + + expect(() => { + ReactTestUtils.findAllInRenderedTree([], 'span'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: an array.', + ); + expect(() => { + ReactTestUtils.scryRenderedDOMComponentsWithClass(10, 'button'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: 10.', + ); + expect(() => { + ReactTestUtils.findRenderedDOMComponentWithClass('hello', 'button'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: hello.', + ); + expect(() => { + ReactTestUtils.scryRenderedDOMComponentsWithTag( + {x: true, y: false}, + 'span', + ); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: object with keys {x, y}.', + ); + const div = document.createElement('div'); + expect(() => { + ReactTestUtils.findRenderedDOMComponentWithTag(div, 'span'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: a DOM node.', + ); + expect(() => { + ReactTestUtils.scryRenderedComponentsWithType(true, 'span'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: true.', + ); + expect(() => { + ReactTestUtils.findRenderedComponentWithType(true, 'span'); + }).toThrow( + 'The first argument must be a React class instance. ' + + 'Instead received: true.', + ); + }); + + describe('Simulate', () => { + // @gate !disableDOMTestUtils + it('should change the value of an input field', () => { + const obj = { + handler: function (e) { + e.persist(); + }, + }; + spyOnDevAndProd(obj, 'handler'); + const container = document.createElement('div'); + const node = ReactDOM.render( + , + container, + ); + + node.value = 'giraffe'; + ReactTestUtils.Simulate.change(node); + + expect(obj.handler).toHaveBeenCalledWith( + expect.objectContaining({target: node}), + ); + }); + + // @gate !disableDOMTestUtils + it('should change the value of an input field in a component', () => { + class SomeComponent extends React.Component { + inputRef = React.createRef(); + render() { + return ( +
+ +
+ ); + } + } + + const obj = { + handler: function (e) { + e.persist(); + }, + }; + spyOnDevAndProd(obj, 'handler'); + const container = document.createElement('div'); + const instance = ReactDOM.render( + , + container, + ); + + const node = instance.inputRef.current; + node.value = 'zebra'; + ReactTestUtils.Simulate.change(node); + + expect(obj.handler).toHaveBeenCalledWith( + expect.objectContaining({target: node}), + ); + }); + + // @gate !disableDOMTestUtils + it('should not warn when used with extra properties', () => { + const CLIENT_X = 100; + + class Component extends React.Component { + handleClick = e => { + expect(e.clientX).toBe(CLIENT_X); + }; + + render() { + return
; + } + } + + const element = document.createElement('div'); + const instance = ReactDOM.render(, element); + ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(instance), { + clientX: CLIENT_X, + }); + }); + + // @gate !disableDOMTestUtils + it('should set the type of the event', () => { + let event; + const stub = jest.fn().mockImplementation(e => { + e.persist(); + event = e; + }); + + const container = document.createElement('div'); + const instance = ReactDOM.render(
, container); + const node = ReactDOM.findDOMNode(instance); + + ReactTestUtils.Simulate.keyDown(node); + + expect(event.type).toBe('keydown'); + expect(event.nativeEvent.type).toBe('keydown'); + }); + + // @gate !disableDOMTestUtils + it('should work with renderIntoDocument', () => { + const onChange = jest.fn(); + + class MyComponent extends React.Component { + render() { + return ( +
+ +
+ ); + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const input = ReactTestUtils.findRenderedDOMComponentWithTag( + instance, + 'input', + ); + input.value = 'giraffe'; + ReactTestUtils.Simulate.change(input); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({target: input}), + ); + }); + + // @gate !disableDOMTestUtils + it('should have mouse enter simulated by test utils', async () => { + const idCallOrder = []; + const recordID = function (id) { + idCallOrder.push(id); + }; + let CHILD; + function Child(props) { + return ( +
(CHILD = current)} + onMouseEnter={() => { + recordID(CHILD); + }} + /> + ); + } + + class ChildWrapper extends React.PureComponent { + render() { + return ; + } + } + + ReactTestUtils.renderIntoDocument( +
+
+ +
+
, + ); + + ReactTestUtils.Simulate.mouseEnter(CHILD); + expect(idCallOrder).toEqual([CHILD]); + }); + + // @gate disableDOMTestUtils + it('throws with a message recommending the relevant React Testing Library API', async () => { + expect(ReactTestUtils.Simulate.click).toThrowError( + '`Simulate` was removed from `react-dom/test-utils`. ' + + 'For testing events, we recommend `fireEvent.click` from `@testing-library/react` instead: https://testing-library.com/docs/dom-testing-library/api-events/.', + ); + }); + }); + + // @gate !disableDOMTestUtils + it('should call setState callback with no arguments', () => { + let mockArgs; + class Component extends React.Component { + componentDidMount() { + this.setState({}, (...args) => (mockArgs = args)); + } + render() { + return false; + } + } + + ReactTestUtils.renderIntoDocument(); + expect(mockArgs.length).toEqual(0); + }); + + // @gate !disableDOMTestUtils + it('should find rendered component with type in document', () => { + class MyComponent extends React.Component { + render() { + return true; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const renderedComponentType = ReactTestUtils.findRenderedComponentWithType( + instance, + MyComponent, + ); + + expect(renderedComponentType).toBe(instance); }); + // @gate disableDOMTestUtils it('throws on every removed function with a special message', async () => { expect(ReactTestUtils.isDOMComponent).toThrowError( '`isDOMComponent` was removed from `react-dom/test-utils`. ' + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', ); }); - - it('Simulate throws with a message recommending the relevant React Testing Library API', async () => { - expect(ReactTestUtils.Simulate.click).toThrowError( - '`Simulate` was removed from `react-dom/test-utils`. ' + - 'For testing events, we recommend `fireEvent.click` from `@testing-library/react` instead: https://testing-library.com/docs/dom-testing-library/api-events/.', - ); - }); }); diff --git a/packages/react-dom/src/__tests__/__snapshots__/ReactTestUtils-test.js.snap b/packages/react-dom/src/__tests__/__snapshots__/ReactTestUtils-test.js.snap new file mode 100644 index 0000000000000..8dda81dcae42b --- /dev/null +++ b/packages/react-dom/src/__tests__/__snapshots__/ReactTestUtils-test.js.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReactTestUtils Simulate should have locally attached media events 1`] = ` +[ + "abort", + "animationEnd", + "animationIteration", + "animationStart", + "auxClick", + "beforeInput", + "blur", + "canPlay", + "canPlayThrough", + "cancel", + "change", + "click", + "close", + "compositionEnd", + "compositionStart", + "compositionUpdate", + "contextMenu", + "copy", + "cut", + "doubleClick", + "drag", + "dragEnd", + "dragEnter", + "dragExit", + "dragLeave", + "dragOver", + "dragStart", + "drop", + "durationChange", + "emptied", + "encrypted", + "ended", + "error", + "focus", + "gotPointerCapture", + "input", + "invalid", + "keyDown", + "keyPress", + "keyUp", + "load", + "loadStart", + "loadedData", + "loadedMetadata", + "lostPointerCapture", + "mouseDown", + "mouseEnter", + "mouseLeave", + "mouseMove", + "mouseOut", + "mouseOver", + "mouseUp", + "paste", + "pause", + "play", + "playing", + "pointerCancel", + "pointerDown", + "pointerEnter", + "pointerLeave", + "pointerMove", + "pointerOut", + "pointerOver", + "pointerUp", + "progress", + "rateChange", + "reset", + "resize", + "scroll", + "seeked", + "seeking", + "select", + "stalled", + "submit", + "suspend", + "timeUpdate", + "toggle", + "touchCancel", + "touchEnd", + "touchMove", + "touchStart", + "transitionEnd", + "volumeChange", + "waiting", + "wheel", +] +`; diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index e6f21c1716208..da5adc9d20a9b 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtils.js +++ b/packages/react-dom/src/test-utils/ReactTestUtils.js @@ -3,58 +3,767 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. + * + * @noflow */ import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import {findCurrentFiberUsingSlowPath} from 'react-reconciler/src/ReactFiberTreeReflection'; +import {get as getInstance} from 'shared/ReactInstanceMap'; +import { + ClassComponent, + FunctionComponent, + HostComponent, + HostHoistable, + HostSingleton, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; +import {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent'; +import {ELEMENT_NODE} from 'react-dom-bindings/src/client/HTMLNodeType'; +import {disableDOMTestUtils, enableFloat} from 'shared/ReactFeatureFlags'; +import assign from 'shared/assign'; +import isArray from 'shared/isArray'; + +// Keep in sync with ReactDOM.js: +const SecretInternals = + ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; +const EventInternals = SecretInternals.Events; +const getInstanceFromNode = EventInternals[0]; +const getNodeFromInstance = EventInternals[1]; +const getFiberCurrentPropsFromNode = EventInternals[2]; +const enqueueStateRestore = EventInternals[3]; +const restoreStateIfNeeded = EventInternals[4]; // TODO: Add a warning if this API is accessed with advice to switch to // importing directly from the React package instead. const act = React.act; -function makeRemovedFunction(name) { - return function () { +function Event(suffix) {} + +let hasWarnedAboutDeprecatedMockComponent = false; + +/** + * @class ReactTestUtils + */ + +function findAllInRenderedFiberTreeInternal(fiber, test) { + if (!fiber) { + return []; + } + const currentParent = findCurrentFiberUsingSlowPath(fiber); + if (!currentParent) { + return []; + } + let node = currentParent; + const ret = []; + while (true) { + if ( + node.tag === HostComponent || + node.tag === HostText || + node.tag === ClassComponent || + node.tag === FunctionComponent || + (enableFloat ? node.tag === HostHoistable : false) || + node.tag === HostSingleton + ) { + const publicInst = node.stateNode; + if (test(publicInst)) { + ret.push(publicInst); + } + } + if (node.child) { + node.child.return = node; + node = node.child; + continue; + } + if (node === currentParent) { + return ret; + } + while (!node.sibling) { + if (!node.return || node.return === currentParent) { + return ret; + } + node = node.return; + } + node.sibling.return = node.return; + node = node.sibling; + } +} + +function validateClassInstance(inst, methodName) { + if (!inst) { + // This is probably too relaxed but it's existing behavior. + return; + } + if (getInstance(inst)) { + // This is a public instance indeed. + return; + } + let received; + const stringified = String(inst); + if (isArray(inst)) { + received = 'an array'; + } else if (inst && inst.nodeType === ELEMENT_NODE && inst.tagName) { + received = 'a DOM node'; + } else if (stringified === '[object Object]') { + received = 'object with keys {' + Object.keys(inst).join(', ') + '}'; + } else { + received = stringified; + } + + throw new Error( + `The first argument must be a React class instance. ` + + `Instead received: ${received}.`, + ); +} + +/** + * Utilities for making it easy to test React components. + * + * See https://reactjs.org/docs/test-utils.html + * + * Todo: Support the entire DOM.scry query syntax. For now, these simple + * utilities will suffice for testing purposes. + * @lends ReactTestUtils + */ +function renderIntoDocument(element) { + if (disableDOMTestUtils) { + throw new Error( + '`renderIntoDocument` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + const div = document.createElement('div'); + // None of our tests actually require attaching the container to the + // DOM, and doing so creates a mess that we rely on test isolation to + // clean up, so we're going to stop honoring the name of this method + // (and probably rename it eventually) if no problems arise. + // document.documentElement.appendChild(div); + return ReactDOM.render(element, div); +} + +function isElement(element) { + if (disableDOMTestUtils) { + throw new Error( + '`isElement` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + return React.isValidElement(element); +} + +function isElementOfType(inst, convenienceConstructor) { + if (disableDOMTestUtils) { + throw new Error( + '`isElementOfType` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + return React.isValidElement(inst) && inst.type === convenienceConstructor; +} + +function isDOMComponent(inst) { + if (disableDOMTestUtils) { + throw new Error( + '`isDOMComponent` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + return !!(inst && inst.nodeType === ELEMENT_NODE && inst.tagName); +} + +function isDOMComponentElement(inst) { + if (disableDOMTestUtils) { + throw new Error( + '`isDOMComponentElement` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + return !!(inst && React.isValidElement(inst) && !!inst.tagName); +} + +function isCompositeComponent(inst) { + if (disableDOMTestUtils) { + throw new Error( + '`isCompositeComponent` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + if (isDOMComponent(inst)) { + // Accessing inst.setState warns; just return false as that'll be what + // this returns when we have DOM nodes as refs directly + return false; + } + return ( + inst != null && + typeof inst.render === 'function' && + typeof inst.setState === 'function' + ); +} + +function isCompositeComponentWithType(inst, type) { + if (disableDOMTestUtils) { + throw new Error( + '`isCompositeComponentWithType` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + if (!isCompositeComponent(inst)) { + return false; + } + const internalInstance = getInstance(inst); + const constructor = internalInstance.type; + return constructor === type; +} + +function findAllInRenderedTree(inst, test) { + if (disableDOMTestUtils) { + throw new Error( + '`findAllInRenderedTree` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(inst, 'findAllInRenderedTree'); + if (!inst) { + return []; + } + const internalInstance = getInstance(inst); + return findAllInRenderedFiberTreeInternal(internalInstance, test); +} + +/** + * Finds all instances of components in the rendered tree that are DOM + * components with the class name matching `className`. + * @return {array} an array of all the matches. + */ +function scryRenderedDOMComponentsWithClass(root, classNames) { + if (disableDOMTestUtils) { + throw new Error( + '`scryRenderedDOMComponentsWithClass` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'scryRenderedDOMComponentsWithClass'); + return findAllInRenderedTree(root, function (inst) { + if (isDOMComponent(inst)) { + let className = inst.className; + if (typeof className !== 'string') { + // SVG, probably. + className = inst.getAttribute('class') || ''; + } + const classList = className.split(/\s+/); + + if (!isArray(classNames)) { + if (classNames === undefined) { + throw new Error( + 'TestUtils.scryRenderedDOMComponentsWithClass expects a ' + + 'className as a second argument.', + ); + } + + classNames = classNames.split(/\s+/); + } + return classNames.every(function (name) { + return classList.indexOf(name) !== -1; + }); + } + return false; + }); +} + +/** + * Like scryRenderedDOMComponentsWithClass but expects there to be one result, + * and returns that one result, or throws exception if there is any other + * number of matches besides one. + * @return {!ReactDOMComponent} The one match. + */ +function findRenderedDOMComponentWithClass(root, className) { + if (disableDOMTestUtils) { + throw new Error( + '`findRenderedDOMComponentWithClass` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'findRenderedDOMComponentWithClass'); + const all = scryRenderedDOMComponentsWithClass(root, className); + if (all.length !== 1) { + throw new Error( + 'Did not find exactly one match (found: ' + + all.length + + ') ' + + 'for class:' + + className, + ); + } + return all[0]; +} + +/** + * Finds all instances of components in the rendered tree that are DOM + * components with the tag name matching `tagName`. + * @return {array} an array of all the matches. + */ +function scryRenderedDOMComponentsWithTag(root, tagName) { + if (disableDOMTestUtils) { + throw new Error( + '`scryRenderedDOMComponentsWithTag` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'scryRenderedDOMComponentsWithTag'); + return findAllInRenderedTree(root, function (inst) { + return ( + isDOMComponent(inst) && + inst.tagName.toUpperCase() === tagName.toUpperCase() + ); + }); +} + +/** + * Like scryRenderedDOMComponentsWithTag but expects there to be one result, + * and returns that one result, or throws exception if there is any other + * number of matches besides one. + * @return {!ReactDOMComponent} The one match. + */ +function findRenderedDOMComponentWithTag(root, tagName) { + if (disableDOMTestUtils) { + throw new Error( + '`findRenderedDOMComponentWithTag` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'findRenderedDOMComponentWithTag'); + const all = scryRenderedDOMComponentsWithTag(root, tagName); + if (all.length !== 1) { + throw new Error( + 'Did not find exactly one match (found: ' + + all.length + + ') ' + + 'for tag:' + + tagName, + ); + } + return all[0]; +} + +/** + * Finds all instances of components with type equal to `componentType`. + * @return {array} an array of all the matches. + */ +function scryRenderedComponentsWithType(root, componentType) { + if (disableDOMTestUtils) { + throw new Error( + '`scryRenderedComponentsWithType` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'scryRenderedComponentsWithType'); + return findAllInRenderedTree(root, function (inst) { + return isCompositeComponentWithType(inst, componentType); + }); +} + +/** + * Same as `scryRenderedComponentsWithType` but expects there to be one result + * and returns that one result, or throws exception if there is any other + * number of matches besides one. + * @return {!ReactComponent} The one match. + */ +function findRenderedComponentWithType(root, componentType) { + if (disableDOMTestUtils) { + throw new Error( + '`findRenderedComponentWithType` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + validateClassInstance(root, 'findRenderedComponentWithType'); + const all = scryRenderedComponentsWithType(root, componentType); + if (all.length !== 1) { + throw new Error( + 'Did not find exactly one match (found: ' + + all.length + + ') ' + + 'for componentType:' + + componentType, + ); + } + return all[0]; +} + +/** + * Pass a mocked component module to this method to augment it with + * useful methods that allow it to be used as a dummy React component. + * Instead of rendering as usual, the component will become a simple + *
containing any provided children. + * + * @param {object} module the mock function object exported from a + * module that defines the component to be mocked + * @param {?string} mockTagName optional dummy root tag name to return + * from render method (overrides + * module.mockTagName if provided) + * @return {object} the ReactTestUtils object (for chaining) + */ +function mockComponent(module, mockTagName) { + if (disableDOMTestUtils) { + throw new Error( + '`mockComponent` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend shallow rendering with Enzyme or React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + if (__DEV__) { + if (!hasWarnedAboutDeprecatedMockComponent) { + hasWarnedAboutDeprecatedMockComponent = true; + console.warn( + 'ReactTestUtils.mockComponent() is deprecated. ' + + 'Use shallow rendering or jest.mock() instead.\n\n' + + 'See https://react.dev/link/test-utils-mock-component for more information.', + ); + } + } + + mockTagName = mockTagName || module.mockTagName || 'div'; + + module.prototype.render.mockImplementation(function () { + return React.createElement(mockTagName, null, this.props.children); + }); + + return this; +} + +function nativeTouchData(x, y) { + if (disableDOMTestUtils) { + throw new Error( + '`nativeTouchData` was removed from `react-dom/test-utils`. ' + + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', + ); + } + + return { + touches: [{pageX: x, pageY: y}], + }; +} + +// Start of inline: the below functions were inlined from +// EventPropagator.js, as they deviated from ReactDOM's newer +// implementations. + +let hasError: boolean = false; +let caughtError: mixed = null; + +/** + * Dispatch the event to the listener. + * @param {SyntheticEvent} event SyntheticEvent to handle + * @param {function} listener Application-level callback + * @param {*} inst Internal component instance + */ +function executeDispatch(event, listener, inst) { + event.currentTarget = getNodeFromInstance(inst); + try { + listener(event); + } catch (error) { + if (!hasError) { + hasError = true; + caughtError = error; + } + } + event.currentTarget = null; +} + +/** + * Standard/simple iteration through an event's collected dispatches. + */ +function executeDispatchesInOrder(event) { + const dispatchListeners = event._dispatchListeners; + const dispatchInstances = event._dispatchInstances; + if (isArray(dispatchListeners)) { + for (let i = 0; i < dispatchListeners.length; i++) { + if (event.isPropagationStopped()) { + break; + } + // Listeners and Instances are two parallel arrays that are always in sync. + executeDispatch(event, dispatchListeners[i], dispatchInstances[i]); + } + } else if (dispatchListeners) { + executeDispatch(event, dispatchListeners, dispatchInstances); + } + event._dispatchListeners = null; + event._dispatchInstances = null; +} + +/** + * Dispatches an event and releases it back into the pool, unless persistent. + * + * @param {?object} event Synthetic event to be dispatched. + * @private + */ +function executeDispatchesAndRelease(event /* ReactSyntheticEvent */) { + if (event) { + executeDispatchesInOrder(event); + + if (!event.isPersistent()) { + event.constructor.release(event); + } + } +} + +function isInteractive(tag) { + return ( + tag === 'button' || + tag === 'input' || + tag === 'select' || + tag === 'textarea' + ); +} + +function getParent(inst) { + do { + inst = inst.return; + // TODO: If this is a HostRoot we might want to bail out. + // That is depending on if we want nested subtrees (layers) to bubble + // events to their parent. We could also go through parentNode on the + // host node but that wouldn't work for React Native and doesn't let us + // do the portal feature. + } while (inst && inst.tag !== HostComponent && inst.tag !== HostSingleton); + if (inst) { + return inst; + } + return null; +} + +/** + * Simulates the traversal of a two-phase, capture/bubble event dispatch. + */ +export function traverseTwoPhase(inst, fn, arg) { + if (disableDOMTestUtils) { throw new Error( - '`' + - name + - '` was removed from `react-dom/test-utils`. ' + + '`traverseTwoPhase` was removed from `react-dom/test-utils`. ' + 'For testing React, we recommend React Testing Library instead: https://testing-library.com/docs/react-testing-library/intro.', ); + } + + const path = []; + while (inst) { + path.push(inst); + inst = getParent(inst); + } + let i; + for (i = path.length; i-- > 0; ) { + fn(path[i], 'captured', arg); + } + for (i = 0; i < path.length; i++) { + fn(path[i], 'bubbled', arg); + } +} + +function shouldPreventMouseEvent(name, type, props) { + switch (name) { + case 'onClick': + case 'onClickCapture': + case 'onDoubleClick': + case 'onDoubleClickCapture': + case 'onMouseDown': + case 'onMouseDownCapture': + case 'onMouseMove': + case 'onMouseMoveCapture': + case 'onMouseUp': + case 'onMouseUpCapture': + case 'onMouseEnter': + return !!(props.disabled && isInteractive(type)); + default: + return false; + } +} + +/** + * @param {object} inst The instance, which is the source of events. + * @param {string} registrationName Name of listener (e.g. `onClick`). + * @return {?function} The stored callback. + */ +function getListener(inst /* Fiber */, registrationName: string) { + // TODO: shouldPreventMouseEvent is DOM-specific and definitely should not + // live here; needs to be moved to a better place soon + const stateNode = inst.stateNode; + if (!stateNode) { + // Work in progress (ex: onload events in incremental mode). + return null; + } + const props = getFiberCurrentPropsFromNode(stateNode); + if (!props) { + // Work in progress. + return null; + } + const listener = props[registrationName]; + if (shouldPreventMouseEvent(registrationName, inst.type, props)) { + return null; + } + + if (listener && typeof listener !== 'function') { + throw new Error( + `Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`, + ); + } + + return listener; +} + +function listenerAtPhase(inst, event, propagationPhase: PropagationPhases) { + let registrationName = event._reactName; + if (propagationPhase === 'captured') { + registrationName += 'Capture'; + } + return getListener(inst, registrationName); +} + +function accumulateDispatches(inst, ignoredDirection, event) { + if (inst && event && event._reactName) { + const registrationName = event._reactName; + const listener = getListener(inst, registrationName); + if (listener) { + if (event._dispatchListeners == null) { + event._dispatchListeners = []; + } + if (event._dispatchInstances == null) { + event._dispatchInstances = []; + } + event._dispatchListeners.push(listener); + event._dispatchInstances.push(inst); + } + } +} + +function accumulateDirectionalDispatches(inst, phase, event) { + if (__DEV__) { + if (!inst) { + console.error('Dispatching inst must not be null'); + } + } + const listener = listenerAtPhase(inst, event, phase); + if (listener) { + if (event._dispatchListeners == null) { + event._dispatchListeners = []; + } + if (event._dispatchInstances == null) { + event._dispatchInstances = []; + } + event._dispatchListeners.push(listener); + event._dispatchInstances.push(inst); + } +} + +function accumulateDirectDispatchesSingle(event) { + if (event && event._reactName) { + accumulateDispatches(event._targetInst, null, event); + } +} + +function accumulateTwoPhaseDispatchesSingle(event) { + if (event && event._reactName) { + traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event); + } +} + +// End of inline + +const Simulate = {}; + +const directDispatchEventTypes = new Set([ + 'mouseEnter', + 'mouseLeave', + 'pointerEnter', + 'pointerLeave', +]); + +/** + * Exports: + * + * - `Simulate.click(Element)` + * - `Simulate.mouseMove(Element)` + * - `Simulate.change(Element)` + * - ... (All keys from event plugin `eventTypes` objects) + */ +function makeSimulator(eventType) { + return function (domNode, eventData) { + if (disableDOMTestUtils) { + throw new Error( + '`Simulate` was removed from `react-dom/test-utils`. ' + + 'For testing events, we recommend `fireEvent.' + + eventType + + '` from `@testing-library/react` instead: https://testing-library.com/docs/dom-testing-library/api-events/.', + ); + } + + if (React.isValidElement(domNode)) { + throw new Error( + 'TestUtils.Simulate expected a DOM node as the first argument but received ' + + 'a React element. Pass the DOM node you wish to simulate the event on instead. ' + + 'Note that TestUtils.Simulate will not work if you are using shallow rendering.', + ); + } + + if (isCompositeComponent(domNode)) { + throw new Error( + 'TestUtils.Simulate expected a DOM node as the first argument but received ' + + 'a component instance. Pass the DOM node you wish to simulate the event on instead.', + ); + } + + const reactName = 'on' + eventType[0].toUpperCase() + eventType.slice(1); + const fakeNativeEvent = new Event(); + fakeNativeEvent.target = domNode; + fakeNativeEvent.type = eventType.toLowerCase(); + + const targetInst = getInstanceFromNode(domNode); + const event = new SyntheticEvent( + reactName, + fakeNativeEvent.type, + targetInst, + fakeNativeEvent, + domNode, + ); + + // Since we aren't using pooling, always persist the event. This will make + // sure it's marked and won't warn when setting additional properties. + event.persist(); + assign(event, eventData); + + if (directDispatchEventTypes.has(eventType)) { + accumulateDirectDispatchesSingle(event); + } else { + accumulateTwoPhaseDispatchesSingle(event); + } + + ReactDOM.unstable_batchedUpdates(function () { + // Normally extractEvent enqueues a state restore, but we'll just always + // do that since we're by-passing it here. + enqueueStateRestore(domNode); + executeDispatchesAndRelease(event); + if (hasError) { + const error = caughtError; + hasError = false; + caughtError = null; + throw error; + } + }); + restoreStateIfNeeded(); }; } -const renderIntoDocument = makeRemovedFunction('renderIntoDocument'); -const isElement = makeRemovedFunction('isElement'); -const isElementOfType = makeRemovedFunction('isElementOfType'); -const isDOMComponent = makeRemovedFunction('isDOMComponent'); -const isDOMComponentElement = makeRemovedFunction('isDOMComponentElement'); -const isCompositeComponent = makeRemovedFunction('isCompositeComponent'); -const isCompositeComponentWithType = makeRemovedFunction( - 'isCompositeComponentWithType', -); -const findAllInRenderedTree = makeRemovedFunction('findAllInRenderedTree'); -const scryRenderedDOMComponentsWithClass = makeRemovedFunction( - 'scryRenderedDOMComponentsWithClass', -); -const findRenderedDOMComponentWithClass = makeRemovedFunction( - 'findRenderedDOMComponentWithClass', -); -const scryRenderedDOMComponentsWithTag = makeRemovedFunction( - 'scryRenderedDOMComponentsWithTag', -); -const findRenderedDOMComponentWithTag = makeRemovedFunction( - 'findRenderedDOMComponentWithTag', -); -const scryRenderedComponentsWithType = makeRemovedFunction( - 'scryRenderedComponentsWithType', -); -const findRenderedComponentWithType = makeRemovedFunction( - 'findRenderedComponentWithType', -); -const mockComponent = makeRemovedFunction('mockComponent'); -const nativeTouchData = makeRemovedFunction('nativeTouchData'); - -// Snapshot of events supported by Simulate before we removed it. -// Do not add new events here since the new ones were never supported in the first place. +// A one-time snapshot with no plans to update. We'll probably want to deprecate Simulate API. const simulatedEventTypes = [ 'blur', 'cancel', @@ -142,23 +851,14 @@ const simulatedEventTypes = [ 'compositionStart', 'compositionUpdate', ]; - -const Simulate = {}; - -simulatedEventTypes.forEach(eventType => { - Simulate[eventType] = function () { - throw new Error( - '`Simulate` was removed from `react-dom/test-utils`. ' + - 'For testing events, we recommend `fireEvent.' + - eventType + - '` from `@testing-library/react` instead: https://testing-library.com/docs/dom-testing-library/api-events/.', - ); - }; -}); +function buildSimulators() { + simulatedEventTypes.forEach(eventType => { + Simulate[eventType] = makeSimulator(eventType); + }); +} +buildSimulators(); export { - act, - // Removed APIs renderIntoDocument, isElement, isElementOfType, @@ -176,4 +876,5 @@ export { mockComponent, nativeTouchData, Simulate, + act, }; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ff03d8849afe2..06e19cd59832a 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -198,6 +198,8 @@ export const enableReactTestRendererWarning = false; // before removing them in stable in the next Major export const disableLegacyMode = __NEXT_MAJOR__; +export const disableDOMTestUtils = __NEXT_MAJOR__; + // HTML boolean attributes need a special PropertyInfoRecord. // Between support of these attributes in browsers and React supporting them as // boolean props library users can use them as `
`. diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 93a8d66ce7da3..10a2f038e7192 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -102,6 +102,7 @@ export const disableStringRefs = false; export const enableReactTestRendererWarning = false; export const disableLegacyMode = false; +export const disableDOMTestUtils = true; export const enableBigIntSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index acc051edcc661..4d2892affe3f7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -96,6 +96,7 @@ export const enableReactTestRendererWarning = false; export const enableBigIntSupport = false; export const disableLegacyMode = false; +export const disableDOMTestUtils = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 366e475a4f57a..67921ccd88b7c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -99,6 +99,7 @@ export const enableReactTestRendererWarning = false; export const enableBigIntSupport = __NEXT_MAJOR__; export const disableLegacyMode = __NEXT_MAJOR__; export const disableLegacyContext = __NEXT_MAJOR__; +export const disableDOMTestUtils = __NEXT_MAJOR__; export const enableNewBooleanProps = __NEXT_MAJOR__; // Flow magic to verify the exports of this file match the original version. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index fd9531385971b..a377452f10cb3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -91,6 +91,7 @@ export const disableStringRefs = false; export const enableReactTestRendererWarning = false; export const disableLegacyMode = false; +export const disableDOMTestUtils = false; export const enableBigIntSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index f6b6be8054609..9288935ce57d4 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -93,6 +93,7 @@ export const disableStringRefs = false; export const enableReactTestRendererWarning = false; export const disableLegacyMode = false; +export const disableDOMTestUtils = false; export const enableBigIntSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index d2364057ecadf..683c3a4ff8705 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -126,5 +126,7 @@ export const disableStringRefs = false; export const disableLegacyMode = false; +export const disableDOMTestUtils = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType);