From 19055fd3f3249fc0fab03740e1b77a4b79edc8d5 Mon Sep 17 00:00:00 2001 From: Ricky Date: Thu, 25 Jan 2024 01:17:03 -0500 Subject: [PATCH] Update ReactUpdates-test (#28061) ## Overview These tests are important for `ReactDOM.render`, so instead of just re-writing them to `createRoot` and losing coverage: - Moved the `.render` tests to `ReactLegacyUpdates` - Re-wrote the tests in `ReactUpdates` to use `createRoot` - Remove `unstable_batchedUpdates` from `ReactUpdates` In a future PR, when I flag `batchedUpdates` with a Noop, I can add the gate to just the tests in `ReactLegacyUpdates`. --- .../src/__tests__/ReactLegacyUpdates-test.js | 1591 +++++++++++++++++ .../src/__tests__/ReactUpdates-test.js | 1042 ++++++----- 2 files changed, 2158 insertions(+), 475 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactLegacyUpdates-test.js diff --git a/packages/react-dom/src/__tests__/ReactLegacyUpdates-test.js b/packages/react-dom/src/__tests__/ReactLegacyUpdates-test.js new file mode 100644 index 0000000000000..d1e54dbb055ca --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactLegacyUpdates-test.js @@ -0,0 +1,1591 @@ +/** + * 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 ReactDOM; +let ReactTestUtils; +let act; +let Scheduler; +let assertLog; + +// Copy of ReactUpdates using ReactDOM.render and ReactDOM.unstable_batchedUpdates. +// Can be deleted when we remove both. +describe('ReactLegacyUpdates', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + ReactTestUtils = require('react-dom/test-utils'); + act = require('internal-test-utils').act; + Scheduler = require('scheduler'); + + const InternalTestUtils = require('internal-test-utils'); + assertLog = InternalTestUtils.assertLog; + }); + + it('should batch state when updating state twice', () => { + let updateCount = 0; + + class Component extends React.Component { + state = {x: 0}; + + componentDidUpdate() { + updateCount++; + } + + render() { + return
{this.state.x}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}); + instance.setState({x: 2}); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch state when updating two different state keys', () => { + let updateCount = 0; + + class Component extends React.Component { + state = {x: 0, y: 0}; + + componentDidUpdate() { + updateCount++; + } + + render() { + return
{`(${this.state.x}, ${this.state.y})`}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + expect(instance.state.y).toBe(0); + + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}); + instance.setState({y: 2}); + expect(instance.state.x).toBe(0); + expect(instance.state.y).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(instance.state.y).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch state and props together', () => { + let updateCount = 0; + + class Component extends React.Component { + state = {y: 0}; + + componentDidUpdate() { + updateCount++; + } + + render() { + return
{`(${this.props.x}, ${this.state.y})`}
; + } + } + + const container = document.createElement('div'); + const instance = ReactDOM.render(, container); + expect(instance.props.x).toBe(0); + expect(instance.state.y).toBe(0); + + ReactDOM.unstable_batchedUpdates(function () { + ReactDOM.render(, container); + instance.setState({y: 2}); + expect(instance.props.x).toBe(0); + expect(instance.state.y).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(instance.props.x).toBe(1); + expect(instance.state.y).toBe(2); + expect(updateCount).toBe(1); + }); + + it('should batch parent/child state updates together', () => { + let parentUpdateCount = 0; + + class Parent extends React.Component { + state = {x: 0}; + childRef = React.createRef(); + + componentDidUpdate() { + parentUpdateCount++; + } + + render() { + return ( +
+ +
+ ); + } + } + + let childUpdateCount = 0; + + class Child extends React.Component { + state = {y: 0}; + + componentDidUpdate() { + childUpdateCount++; + } + + render() { + return
{this.props.x + this.state.y}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const child = instance.childRef.current; + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}); + child.setState({y: 2}); + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + expect(parentUpdateCount).toBe(0); + expect(childUpdateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(child.state.y).toBe(2); + expect(parentUpdateCount).toBe(1); + expect(childUpdateCount).toBe(1); + }); + + it('should batch child/parent state updates together', () => { + let parentUpdateCount = 0; + + class Parent extends React.Component { + state = {x: 0}; + childRef = React.createRef(); + + componentDidUpdate() { + parentUpdateCount++; + } + + render() { + return ( +
+ +
+ ); + } + } + + let childUpdateCount = 0; + + class Child extends React.Component { + state = {y: 0}; + + componentDidUpdate() { + childUpdateCount++; + } + + render() { + return
{this.props.x + this.state.y}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const child = instance.childRef.current; + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + + ReactDOM.unstable_batchedUpdates(function () { + child.setState({y: 2}); + instance.setState({x: 1}); + expect(instance.state.x).toBe(0); + expect(child.state.y).toBe(0); + expect(parentUpdateCount).toBe(0); + expect(childUpdateCount).toBe(0); + }); + + expect(instance.state.x).toBe(1); + expect(child.state.y).toBe(2); + expect(parentUpdateCount).toBe(1); + + // Batching reduces the number of updates here to 1. + expect(childUpdateCount).toBe(1); + }); + + it('should support chained state updates', () => { + let updateCount = 0; + + class Component extends React.Component { + state = {x: 0}; + + componentDidUpdate() { + updateCount++; + } + + render() { + return
{this.state.x}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + let innerCallbackRun = false; + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}, function () { + instance.setState({x: 2}, function () { + expect(this).toBe(instance); + innerCallbackRun = true; + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(2); + }); + expect(instance.state.x).toBe(1); + expect(updateCount).toBe(1); + }); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(innerCallbackRun).toBeTruthy(); + expect(instance.state.x).toBe(2); + expect(updateCount).toBe(2); + }); + + it('should batch forceUpdate together', () => { + let shouldUpdateCount = 0; + let updateCount = 0; + + class Component extends React.Component { + state = {x: 0}; + + shouldComponentUpdate() { + shouldUpdateCount++; + } + + componentDidUpdate() { + updateCount++; + } + + render() { + return
{this.state.x}
; + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + expect(instance.state.x).toBe(0); + + let callbacksRun = 0; + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}, function () { + callbacksRun++; + }); + instance.forceUpdate(function () { + callbacksRun++; + }); + expect(instance.state.x).toBe(0); + expect(updateCount).toBe(0); + }); + + expect(callbacksRun).toBe(2); + // shouldComponentUpdate shouldn't be called since we're forcing + expect(shouldUpdateCount).toBe(0); + expect(instance.state.x).toBe(1); + expect(updateCount).toBe(1); + }); + + it('should update children even if parent blocks updates', () => { + let parentRenderCount = 0; + let childRenderCount = 0; + + class Parent extends React.Component { + childRef = React.createRef(); + + shouldComponentUpdate() { + return false; + } + + render() { + parentRenderCount++; + return ; + } + } + + class Child extends React.Component { + render() { + childRenderCount++; + return
; + } + } + + expect(parentRenderCount).toBe(0); + expect(childRenderCount).toBe(0); + + let instance = ; + instance = ReactTestUtils.renderIntoDocument(instance); + + expect(parentRenderCount).toBe(1); + expect(childRenderCount).toBe(1); + + ReactDOM.unstable_batchedUpdates(function () { + instance.setState({x: 1}); + }); + + expect(parentRenderCount).toBe(1); + expect(childRenderCount).toBe(1); + + ReactDOM.unstable_batchedUpdates(function () { + instance.childRef.current.setState({x: 1}); + }); + + expect(parentRenderCount).toBe(1); + expect(childRenderCount).toBe(2); + }); + + it('should not reconcile children passed via props', () => { + let numMiddleRenders = 0; + let numBottomRenders = 0; + + class Top extends React.Component { + render() { + return ( + + + + ); + } + } + + class Middle extends React.Component { + componentDidMount() { + this.forceUpdate(); + } + + render() { + numMiddleRenders++; + return React.Children.only(this.props.children); + } + } + + class Bottom extends React.Component { + render() { + numBottomRenders++; + return null; + } + } + + ReactTestUtils.renderIntoDocument(); + expect(numMiddleRenders).toBe(2); + expect(numBottomRenders).toBe(1); + }); + + it('should flow updates correctly', () => { + let willUpdates = []; + let didUpdates = []; + + const UpdateLoggingMixin = { + UNSAFE_componentWillUpdate: function () { + willUpdates.push(this.constructor.displayName); + }, + componentDidUpdate: function () { + didUpdates.push(this.constructor.displayName); + }, + }; + + class Box extends React.Component { + boxDivRef = React.createRef(); + + render() { + return
{this.props.children}
; + } + } + Object.assign(Box.prototype, UpdateLoggingMixin); + + class Child extends React.Component { + spanRef = React.createRef(); + + render() { + return child; + } + } + Object.assign(Child.prototype, UpdateLoggingMixin); + + class Switcher extends React.Component { + state = {tabKey: 'hello'}; + boxRef = React.createRef(); + switcherDivRef = React.createRef(); + render() { + const child = this.props.children; + + return ( + +
+ {child} +
+
+ ); + } + } + Object.assign(Switcher.prototype, UpdateLoggingMixin); + + class App extends React.Component { + switcherRef = React.createRef(); + childRef = React.createRef(); + + render() { + return ( + + + + ); + } + } + Object.assign(App.prototype, UpdateLoggingMixin); + + let root = ; + root = ReactTestUtils.renderIntoDocument(root); + + function expectUpdates(desiredWillUpdates, desiredDidUpdates) { + let i; + for (i = 0; i < desiredWillUpdates; i++) { + expect(willUpdates).toContain(desiredWillUpdates[i]); + } + for (i = 0; i < desiredDidUpdates; i++) { + expect(didUpdates).toContain(desiredDidUpdates[i]); + } + willUpdates = []; + didUpdates = []; + } + + function triggerUpdate(c) { + c.setState({x: 1}); + } + + function testUpdates(components, desiredWillUpdates, desiredDidUpdates) { + let i; + + ReactDOM.unstable_batchedUpdates(function () { + for (i = 0; i < components.length; i++) { + triggerUpdate(components[i]); + } + }); + + expectUpdates(desiredWillUpdates, desiredDidUpdates); + + // Try them in reverse order + + ReactDOM.unstable_batchedUpdates(function () { + for (i = components.length - 1; i >= 0; i--) { + triggerUpdate(components[i]); + } + }); + + expectUpdates(desiredWillUpdates, desiredDidUpdates); + } + testUpdates( + [root.switcherRef.current.boxRef.current, root.switcherRef.current], + // Owner-child relationships have inverse will and did + ['Switcher', 'Box'], + ['Box', 'Switcher'], + ); + + testUpdates( + [root.childRef.current, root.switcherRef.current.boxRef.current], + // Not owner-child so reconcile independently + ['Box', 'Child'], + ['Box', 'Child'], + ); + + testUpdates( + [root.childRef.current, root.switcherRef.current], + // Switcher owns Box and Child, Box does not own Child + ['Switcher', 'Box', 'Child'], + ['Box', 'Switcher', 'Child'], + ); + }); + + it('should queue mount-ready handlers across different roots', () => { + // We'll define two components A and B, then update both of them. When A's + // componentDidUpdate handlers is called, B's DOM should already have been + // updated. + + const bContainer = document.createElement('div'); + + let b; + + let aUpdated = false; + + class A extends React.Component { + state = {x: 0}; + + componentDidUpdate() { + expect(ReactDOM.findDOMNode(b).textContent).toBe('B1'); + aUpdated = true; + } + + render() { + let portal = null; + // If we're using Fiber, we use Portals instead to achieve this. + portal = ReactDOM.createPortal( (b = n)} />, bContainer); + return ( +
+ A{this.state.x} + {portal} +
+ ); + } + } + + class B extends React.Component { + state = {x: 0}; + + render() { + return
B{this.state.x}
; + } + } + + const a = ReactTestUtils.renderIntoDocument(); + ReactDOM.unstable_batchedUpdates(function () { + a.setState({x: 1}); + b.setState({x: 1}); + }); + + expect(aUpdated).toBe(true); + }); + + it('should flush updates in the correct order', () => { + const updates = []; + + class Outer extends React.Component { + state = {x: 0}; + innerRef = React.createRef(); + + render() { + updates.push('Outer-render-' + this.state.x); + return ( +
+ +
+ ); + } + + componentDidUpdate() { + const x = this.state.x; + updates.push('Outer-didUpdate-' + x); + updates.push('Inner-setState-' + x); + this.innerRef.current.setState({x: x}, function () { + updates.push('Inner-callback-' + x); + }); + } + } + + class Inner extends React.Component { + state = {x: 0}; + + render() { + updates.push('Inner-render-' + this.props.x + '-' + this.state.x); + return
; + } + + componentDidUpdate() { + updates.push('Inner-didUpdate-' + this.props.x + '-' + this.state.x); + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + + updates.push('Outer-setState-1'); + instance.setState({x: 1}, function () { + updates.push('Outer-callback-1'); + updates.push('Outer-setState-2'); + instance.setState({x: 2}, function () { + updates.push('Outer-callback-2'); + }); + }); + + /* eslint-disable indent */ + expect(updates).toEqual([ + 'Outer-render-0', + 'Inner-render-0-0', + + 'Outer-setState-1', + 'Outer-render-1', + 'Inner-render-1-0', + 'Inner-didUpdate-1-0', + 'Outer-didUpdate-1', + // Happens in a batch, so don't re-render yet + 'Inner-setState-1', + 'Outer-callback-1', + + // Happens in a batch + 'Outer-setState-2', + + // Flush batched updates all at once + 'Outer-render-2', + 'Inner-render-2-1', + 'Inner-didUpdate-2-1', + 'Inner-callback-1', + 'Outer-didUpdate-2', + 'Inner-setState-2', + 'Outer-callback-2', + 'Inner-render-2-2', + 'Inner-didUpdate-2-2', + 'Inner-callback-2', + ]); + /* eslint-enable indent */ + }); + + it('should flush updates in the correct order across roots', () => { + const instances = []; + const updates = []; + + class MockComponent extends React.Component { + render() { + updates.push(this.props.depth); + return
; + } + + componentDidMount() { + instances.push(this); + if (this.props.depth < this.props.count) { + ReactDOM.render( + , + ReactDOM.findDOMNode(this), + ); + } + } + } + + ReactTestUtils.renderIntoDocument(); + + expect(updates).toEqual([0, 1, 2]); + + ReactDOM.unstable_batchedUpdates(function () { + // Simulate update on each component from top to bottom. + instances.forEach(function (instance) { + instance.forceUpdate(); + }); + }); + + expect(updates).toEqual([0, 1, 2, 0, 1, 2]); + }); + + it('should queue nested updates', () => { + // See https://github.com/facebook/react/issues/1147 + + class X extends React.Component { + state = {s: 0}; + + render() { + if (this.state.s === 0) { + return ( +
+ 0 +
+ ); + } else { + return
1
; + } + } + + go = () => { + this.setState({s: 1}); + this.setState({s: 0}); + this.setState({s: 1}); + }; + } + + class Y extends React.Component { + render() { + return ( +
+ +
+ ); + } + } + + class Z extends React.Component { + render() { + return
; + } + + UNSAFE_componentWillUpdate() { + x.go(); + } + } + + const x = ReactTestUtils.renderIntoDocument(); + const y = ReactTestUtils.renderIntoDocument(); + expect(ReactDOM.findDOMNode(x).textContent).toBe('0'); + + y.forceUpdate(); + expect(ReactDOM.findDOMNode(x).textContent).toBe('1'); + }); + + it('should queue updates from during mount', () => { + // See https://github.com/facebook/react/issues/1353 + let a; + + class A extends React.Component { + state = {x: 0}; + + UNSAFE_componentWillMount() { + a = this; + } + + render() { + return
A{this.state.x}
; + } + } + + class B extends React.Component { + UNSAFE_componentWillMount() { + a.setState({x: 1}); + } + + render() { + return
; + } + } + + ReactDOM.unstable_batchedUpdates(function () { + ReactTestUtils.renderIntoDocument( + , + ); + }); + + expect(a.state.x).toBe(1); + expect(ReactDOM.findDOMNode(a).textContent).toBe('A1'); + }); + + it('calls componentWillReceiveProps setState callback properly', () => { + let callbackCount = 0; + + class A extends React.Component { + state = {x: this.props.x}; + + UNSAFE_componentWillReceiveProps(nextProps) { + const newX = nextProps.x; + this.setState({x: newX}, function () { + // State should have updated by the time this callback gets called + expect(this.state.x).toBe(newX); + callbackCount++; + }); + } + + render() { + return
{this.state.x}
; + } + } + + const container = document.createElement('div'); + ReactDOM.render(
, container); + ReactDOM.render(, container); + expect(callbackCount).toBe(1); + }); + + it('does not call render after a component as been deleted', () => { + let renderCount = 0; + let componentB = null; + + class B extends React.Component { + state = {updates: 0}; + + componentDidMount() { + componentB = this; + } + + render() { + renderCount++; + return
; + } + } + + class A extends React.Component { + state = {showB: true}; + + render() { + return this.state.showB ? :
; + } + } + + const component = ReactTestUtils.renderIntoDocument(); + + ReactDOM.unstable_batchedUpdates(function () { + // B will have scheduled an update but the batching should ensure that its + // update never fires. + componentB.setState({updates: 1}); + component.setState({showB: false}); + }); + + expect(renderCount).toBe(1); + }); + + it('throws in setState if the update callback is not a function', () => { + function Foo() { + this.a = 1; + this.b = 2; + } + + class A extends React.Component { + state = {}; + + render() { + return
; + } + } + + let component = ReactTestUtils.renderIntoDocument(); + + expect(() => { + expect(() => component.setState({}, 'no')).toErrorDev( + 'setState(...): Expected the last optional `callback` argument to be ' + + 'a function. Instead received: no.', + ); + }).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: no', + ); + component = ReactTestUtils.renderIntoDocument(); + expect(() => { + expect(() => component.setState({}, {foo: 'bar'})).toErrorDev( + 'setState(...): Expected the last optional `callback` argument to be ' + + 'a function. Instead received: [object Object].', + ); + }).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: [object Object]', + ); + // Make sure the warning is deduplicated and doesn't fire again + component = ReactTestUtils.renderIntoDocument(); + expect(() => component.setState({}, new Foo())).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: [object Object]', + ); + }); + + it('throws in forceUpdate if the update callback is not a function', () => { + function Foo() { + this.a = 1; + this.b = 2; + } + + class A extends React.Component { + state = {}; + + render() { + return
; + } + } + + let component = ReactTestUtils.renderIntoDocument(); + + expect(() => { + expect(() => component.forceUpdate('no')).toErrorDev( + 'forceUpdate(...): Expected the last optional `callback` argument to be ' + + 'a function. Instead received: no.', + ); + }).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: no', + ); + component = ReactTestUtils.renderIntoDocument(); + expect(() => { + expect(() => component.forceUpdate({foo: 'bar'})).toErrorDev( + 'forceUpdate(...): Expected the last optional `callback` argument to be ' + + 'a function. Instead received: [object Object].', + ); + }).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: [object Object]', + ); + // Make sure the warning is deduplicated and doesn't fire again + component = ReactTestUtils.renderIntoDocument(); + expect(() => component.forceUpdate(new Foo())).toThrowError( + 'Invalid argument passed as callback. Expected a function. Instead ' + + 'received: [object Object]', + ); + }); + + it('does not update one component twice in a batch (#2410)', () => { + class Parent extends React.Component { + childRef = React.createRef(); + + getChild = () => { + return this.childRef.current; + }; + + render() { + return ; + } + } + + let renderCount = 0; + let postRenderCount = 0; + let once = false; + + class Child extends React.Component { + state = {updated: false}; + + UNSAFE_componentWillUpdate() { + if (!once) { + once = true; + this.setState({updated: true}); + } + } + + componentDidMount() { + expect(renderCount).toBe(postRenderCount + 1); + postRenderCount++; + } + + componentDidUpdate() { + expect(renderCount).toBe(postRenderCount + 1); + postRenderCount++; + } + + render() { + expect(renderCount).toBe(postRenderCount); + renderCount++; + return
; + } + } + + const parent = ReactTestUtils.renderIntoDocument(); + const child = parent.getChild(); + ReactDOM.unstable_batchedUpdates(function () { + parent.forceUpdate(); + child.forceUpdate(); + }); + }); + + it('does not update one component twice in a batch (#6371)', () => { + let callbacks = []; + function emitChange() { + callbacks.forEach(c => c()); + } + + class App extends React.Component { + constructor(props) { + super(props); + this.state = {showChild: true}; + } + componentDidMount() { + this.setState({showChild: false}); + } + render() { + return ( +
+ + {this.state.showChild && } +
+ ); + } + } + + class EmitsChangeOnUnmount extends React.Component { + componentWillUnmount() { + emitChange(); + } + render() { + return null; + } + } + + class ForceUpdatesOnChange extends React.Component { + componentDidMount() { + this.onChange = () => this.forceUpdate(); + this.onChange(); + callbacks.push(this.onChange); + } + componentWillUnmount() { + callbacks = callbacks.filter(c => c !== this.onChange); + } + render() { + return
; + } + } + + ReactDOM.render(, document.createElement('div')); + }); + + it('unstable_batchedUpdates should return value from a callback', () => { + const result = ReactDOM.unstable_batchedUpdates(function () { + return 42; + }); + expect(result).toEqual(42); + }); + + it('unmounts and remounts a root in the same batch', () => { + const container = document.createElement('div'); + ReactDOM.render(a, container); + ReactDOM.unstable_batchedUpdates(function () { + ReactDOM.unmountComponentAtNode(container); + ReactDOM.render(b, container); + }); + expect(container.textContent).toBe('b'); + }); + + it('handles reentrant mounting in synchronous mode', () => { + let mounts = 0; + class Editor extends React.Component { + render() { + return
{this.props.text}
; + } + componentDidMount() { + mounts++; + // This should be called only once but we guard just in case. + if (!this.props.rendered) { + this.props.onChange({rendered: true}); + } + } + } + + const container = document.createElement('div'); + function render() { + ReactDOM.render( + { + props = {...props, ...newProps}; + render(); + }} + {...props} + />, + container, + ); + } + + let props = {text: 'hello', rendered: false}; + render(); + props = {...props, text: 'goodbye'}; + render(); + expect(container.textContent).toBe('goodbye'); + expect(mounts).toBe(1); + }); + + it('mounts and unmounts are sync even in a batch', () => { + const ops = []; + const container = document.createElement('div'); + ReactDOM.unstable_batchedUpdates(() => { + ReactDOM.render(
Hello
, container); + ops.push(container.textContent); + ReactDOM.unmountComponentAtNode(container); + ops.push(container.textContent); + }); + expect(ops).toEqual(['Hello', '']); + }); + + it( + 'in legacy mode, updates in componentWillUpdate and componentDidUpdate ' + + 'should both flush in the immediately subsequent commit', + () => { + const ops = []; + class Foo extends React.Component { + state = {a: false, b: false}; + UNSAFE_componentWillUpdate(_, nextState) { + if (!nextState.a) { + this.setState({a: true}); + } + } + componentDidUpdate() { + ops.push('Foo updated'); + if (!this.state.b) { + this.setState({b: true}); + } + } + render() { + ops.push(`a: ${this.state.a}, b: ${this.state.b}`); + return null; + } + } + + const container = document.createElement('div'); + // Mount + ReactDOM.render(, container); + // Root update + ReactDOM.render(, container); + expect(ops).toEqual([ + // Mount + 'a: false, b: false', + // Root update + 'a: false, b: false', + 'Foo updated', + // Subsequent update (both a and b should have flushed) + 'a: true, b: true', + 'Foo updated', + // There should not be any additional updates + ]); + }, + ); + + it( + 'in legacy mode, updates in componentWillUpdate and componentDidUpdate ' + + '(on a sibling) should both flush in the immediately subsequent commit', + () => { + const ops = []; + class Foo extends React.Component { + state = {a: false}; + UNSAFE_componentWillUpdate(_, nextState) { + if (!nextState.a) { + this.setState({a: true}); + } + } + componentDidUpdate() { + ops.push('Foo updated'); + } + render() { + ops.push(`a: ${this.state.a}`); + return null; + } + } + + class Bar extends React.Component { + state = {b: false}; + componentDidUpdate() { + ops.push('Bar updated'); + if (!this.state.b) { + this.setState({b: true}); + } + } + render() { + ops.push(`b: ${this.state.b}`); + return null; + } + } + + const container = document.createElement('div'); + // Mount + ReactDOM.render( +
+ + +
, + container, + ); + // Root update + ReactDOM.render( +
+ + +
, + container, + ); + expect(ops).toEqual([ + // Mount + 'a: false', + 'b: false', + // Root update + 'a: false', + 'b: false', + 'Foo updated', + 'Bar updated', + // Subsequent update (both a and b should have flushed) + 'a: true', + 'b: true', + 'Foo updated', + 'Bar updated', + // There should not be any additional updates + ]); + }, + ); + + it('uses correct base state for setState inside render phase', () => { + const ops = []; + + class Foo extends React.Component { + state = {step: 0}; + render() { + const memoizedStep = this.state.step; + this.setState(baseState => { + const baseStep = baseState.step; + ops.push(`base: ${baseStep}, memoized: ${memoizedStep}`); + return baseStep === 0 ? {step: 1} : null; + }); + return null; + } + } + + const container = document.createElement('div'); + expect(() => ReactDOM.render(, container)).toErrorDev( + 'Cannot update during an existing state transition', + ); + expect(ops).toEqual(['base: 0, memoized: 0', 'base: 1, memoized: 1']); + }); + + it('does not re-render if state update is null', () => { + const container = document.createElement('div'); + + let instance; + let ops = []; + class Foo extends React.Component { + render() { + instance = this; + ops.push('render'); + return
; + } + } + ReactDOM.render(, container); + + ops = []; + instance.setState(() => null); + expect(ops).toEqual([]); + }); + + // Will change once we switch to async by default + it('synchronously renders hidden subtrees', () => { + const container = document.createElement('div'); + let ops = []; + + function Baz() { + ops.push('Baz'); + return null; + } + + function Bar() { + ops.push('Bar'); + return null; + } + + function Foo() { + ops.push('Foo'); + return ( +
+ + +
+ ); + } + + // Mount + ReactDOM.render(, container); + expect(ops).toEqual(['Foo', 'Bar', 'Baz']); + ops = []; + + // Update + ReactDOM.render(, container); + expect(ops).toEqual(['Foo', 'Bar', 'Baz']); + }); + + it('can render ridiculously large number of roots without triggering infinite update loop error', () => { + class Foo extends React.Component { + componentDidMount() { + const limit = 1200; + for (let i = 0; i < limit; i++) { + if (i < limit - 1) { + ReactDOM.render(
, document.createElement('div')); + } else { + ReactDOM.render(
, document.createElement('div'), () => { + // The "nested update limit" error isn't thrown until setState + this.setState({}); + }); + } + } + } + render() { + return null; + } + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + }); + + it('resets the update counter for unrelated updates', () => { + const container = document.createElement('div'); + const ref = React.createRef(); + + class EventuallyTerminating extends React.Component { + state = {step: 0}; + componentDidMount() { + this.setState({step: 1}); + } + componentDidUpdate() { + if (this.state.step < limit) { + this.setState({step: this.state.step + 1}); + } + } + render() { + return this.state.step; + } + } + + let limit = 55; + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + + // Verify that we don't go over the limit if these updates are unrelated. + limit -= 10; + ReactDOM.render(, container); + expect(container.textContent).toBe(limit.toString()); + ref.current.setState({step: 0}); + expect(container.textContent).toBe(limit.toString()); + ref.current.setState({step: 0}); + expect(container.textContent).toBe(limit.toString()); + + limit += 10; + expect(() => { + ref.current.setState({step: 0}); + }).toThrow('Maximum'); + expect(ref.current).toBe(null); + }); + + it('does not fall into an infinite update loop', () => { + class NonTerminating extends React.Component { + state = {step: 0}; + componentDidMount() { + this.setState({step: 1}); + } + UNSAFE_componentWillUpdate() { + this.setState({step: 2}); + } + render() { + return ( +
+ Hello {this.props.name} + {this.state.step} +
+ ); + } + } + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + }); + + it('does not fall into an infinite update loop with useLayoutEffect', () => { + function NonTerminating() { + const [step, setStep] = React.useState(0); + React.useLayoutEffect(() => { + setStep(x => x + 1); + }); + return step; + } + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + }); + + it('can recover after falling into an infinite update loop', () => { + class NonTerminating extends React.Component { + state = {step: 0}; + componentDidMount() { + this.setState({step: 1}); + } + componentDidUpdate() { + this.setState({step: 2}); + } + render() { + return this.state.step; + } + } + + class Terminating extends React.Component { + state = {step: 0}; + componentDidMount() { + this.setState({step: 1}); + } + render() { + return this.state.step; + } + } + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + + ReactDOM.render(, container); + expect(container.textContent).toBe('1'); + + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + + ReactDOM.render(, container); + expect(container.textContent).toBe('1'); + }); + + it('does not fall into mutually recursive infinite update loop with same container', () => { + // Note: this test would fail if there were two or more different roots. + + class A extends React.Component { + componentDidMount() { + ReactDOM.render(, container); + } + render() { + return null; + } + } + + class B extends React.Component { + componentDidMount() { + ReactDOM.render(
, container); + } + render() { + return null; + } + } + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + }); + + it('does not fall into an infinite error loop', () => { + function BadRender() { + throw new Error('error'); + } + + class ErrorBoundary extends React.Component { + componentDidCatch() { + // Schedule a no-op state update to avoid triggering a DEV warning in the test. + this.setState({}); + + this.props.parent.remount(); + } + render() { + return ; + } + } + + class NonTerminating extends React.Component { + state = {step: 0}; + remount() { + this.setState(state => ({step: state.step + 1})); + } + render() { + return ; + } + } + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Maximum'); + }); + + it('can schedule ridiculously many updates within the same batch without triggering a maximum update error', () => { + const subscribers = []; + + class Child extends React.Component { + state = {value: 'initial'}; + componentDidMount() { + subscribers.push(this); + } + render() { + return null; + } + } + + class App extends React.Component { + render() { + const children = []; + for (let i = 0; i < 1200; i++) { + children.push(); + } + return children; + } + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + + ReactDOM.unstable_batchedUpdates(() => { + subscribers.forEach(s => { + s.setState({value: 'update'}); + }); + }); + }); + + // TODO: Replace this branch with @gate pragmas + if (__DEV__) { + it('can have nested updates if they do not cross the limit', async () => { + let _setStep; + const LIMIT = 50; + + function Terminating() { + const [step, setStep] = React.useState(0); + _setStep = setStep; + React.useEffect(() => { + if (step < LIMIT) { + setStep(x => x + 1); + } + }); + Scheduler.log(step); + return step; + } + + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + expect(container.textContent).toBe('50'); + await act(() => { + _setStep(0); + }); + expect(container.textContent).toBe('50'); + }); + + it('can have many updates inside useEffect without triggering a warning', async () => { + function Terminating() { + const [step, setStep] = React.useState(0); + React.useEffect(() => { + for (let i = 0; i < 1000; i++) { + setStep(x => x + 1); + } + Scheduler.log('Done'); + }, []); + return step; + } + + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + + assertLog(['Done']); + expect(container.textContent).toBe('1000'); + }); + } +}); diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 67abeb6dd0ad6..b460e9247646f 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -48,226 +48,283 @@ describe('ReactUpdates', () => { ); } - it('should batch state when updating state twice', () => { - let updateCount = 0; - - class Component extends React.Component { - state = {x: 0}; - - componentDidUpdate() { - updateCount++; - } + it('should batch state when updating state twice', async () => { + let componentState; + let setState; + + function Component() { + const [state, _setState] = React.useState(0); + componentState = state; + setState = _setState; + React.useLayoutEffect(() => { + Scheduler.log('Commit'); + }); - render() { - return
{this.state.x}
; - } + return
{state}
; } - const instance = ReactTestUtils.renderIntoDocument(); - expect(instance.state.x).toBe(0); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - ReactDOM.unstable_batchedUpdates(function () { - instance.setState({x: 1}); - instance.setState({x: 2}); - expect(instance.state.x).toBe(0); - expect(updateCount).toBe(0); + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('0'); + + await act(() => { + setState(1); + setState(2); + expect(componentState).toBe(0); + expect(container.firstChild.textContent).toBe('0'); + assertLog([]); }); - expect(instance.state.x).toBe(2); - expect(updateCount).toBe(1); + expect(componentState).toBe(2); + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('2'); }); - it('should batch state when updating two different state keys', () => { - let updateCount = 0; + it('should batch state when updating two different states', async () => { + let componentStateA; + let componentStateB; + let setStateA; + let setStateB; - class Component extends React.Component { - state = {x: 0, y: 0}; + function Component() { + const [stateA, _setStateA] = React.useState(0); + const [stateB, _setStateB] = React.useState(0); + componentStateA = stateA; + componentStateB = stateB; + setStateA = _setStateA; + setStateB = _setStateB; - componentDidUpdate() { - updateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Commit'); + }); - render() { - return ( -
- ({this.state.x}, {this.state.y}) -
- ); - } + return ( +
+ {stateA} {stateB} +
+ ); } - const instance = ReactTestUtils.renderIntoDocument(); - expect(instance.state.x).toBe(0); - expect(instance.state.y).toBe(0); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - ReactDOM.unstable_batchedUpdates(function () { - instance.setState({x: 1}); - instance.setState({y: 2}); - expect(instance.state.x).toBe(0); - expect(instance.state.y).toBe(0); - expect(updateCount).toBe(0); + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('0 0'); + + await act(() => { + setStateA(1); + setStateB(2); + expect(componentStateA).toBe(0); + expect(componentStateB).toBe(0); + expect(container.firstChild.textContent).toBe('0 0'); + assertLog([]); }); - expect(instance.state.x).toBe(1); - expect(instance.state.y).toBe(2); - expect(updateCount).toBe(1); + expect(componentStateA).toBe(1); + expect(componentStateB).toBe(2); + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('1 2'); }); - it('should batch state and props together', () => { - let updateCount = 0; + it('should batch state and props together', async () => { + let setState; + let componentProp; + let componentState; - class Component extends React.Component { - state = {y: 0}; + function Component({prop}) { + const [state, _setState] = React.useState(0); + componentProp = prop; + componentState = state; + setState = _setState; - componentDidUpdate() { - updateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Commit'); + }); - render() { - return ( -
- ({this.props.x}, {this.state.y}) -
- ); - } + return ( +
+ {prop} {state} +
+ ); } const container = document.createElement('div'); - const instance = ReactDOM.render(, container); - expect(instance.props.x).toBe(0); - expect(instance.state.y).toBe(0); - - ReactDOM.unstable_batchedUpdates(function () { - ReactDOM.render(, container); - instance.setState({y: 2}); - expect(instance.props.x).toBe(0); - expect(instance.state.y).toBe(0); - expect(updateCount).toBe(0); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('0 0'); + + await act(() => { + root.render(); + setState(2); + expect(componentProp).toBe(0); + expect(componentState).toBe(0); + expect(container.firstChild.textContent).toBe('0 0'); + assertLog([]); }); - expect(instance.props.x).toBe(1); - expect(instance.state.y).toBe(2); - expect(updateCount).toBe(1); + expect(componentProp).toBe(1); + expect(componentState).toBe(2); + assertLog(['Commit']); + expect(container.firstChild.textContent).toBe('1 2'); }); - it('should batch parent/child state updates together', () => { - let parentUpdateCount = 0; + it('should batch parent/child state updates together', async () => { + let childRef; + let parentState; + let childState; + let setParentState; + let setChildState; - class Parent extends React.Component { - state = {x: 0}; - childRef = React.createRef(); + function Parent() { + const [state, _setState] = React.useState(0); + parentState = state; + setParentState = _setState; - componentDidUpdate() { - parentUpdateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Parent Commit'); + }); - render() { - return ( -
- -
- ); - } + return ( +
+ +
+ ); } - let childUpdateCount = 0; - - class Child extends React.Component { - state = {y: 0}; + function Child({prop}) { + const [state, _setState] = React.useState(0); + childState = state; + setChildState = _setState; - componentDidUpdate() { - childUpdateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Child Commit'); + }); - render() { - return
{this.props.x + this.state.y}
; - } + return ( +
{ + childRef = ref; + }}> + {prop} {state} +
+ ); } - const instance = ReactTestUtils.renderIntoDocument(); - const child = instance.childRef.current; - expect(instance.state.x).toBe(0); - expect(child.state.y).toBe(0); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - ReactDOM.unstable_batchedUpdates(function () { - instance.setState({x: 1}); - child.setState({y: 2}); - expect(instance.state.x).toBe(0); - expect(child.state.y).toBe(0); - expect(parentUpdateCount).toBe(0); - expect(childUpdateCount).toBe(0); + assertLog(['Child Commit', 'Parent Commit']); + expect(childRef.textContent).toBe('0 0'); + + await act(() => { + // Parent update first. + setParentState(1); + setChildState(2); + expect(parentState).toBe(0); + expect(childState).toBe(0); + expect(childRef.textContent).toBe('0 0'); + assertLog([]); }); - expect(instance.state.x).toBe(1); - expect(child.state.y).toBe(2); - expect(parentUpdateCount).toBe(1); - expect(childUpdateCount).toBe(1); + expect(parentState).toBe(1); + expect(childState).toBe(2); + expect(childRef.textContent).toBe('1 2'); + assertLog(['Child Commit', 'Parent Commit']); }); - it('should batch child/parent state updates together', () => { - let parentUpdateCount = 0; + it('should batch child/parent state updates together', async () => { + let childRef; + let parentState; + let childState; + let setParentState; + let setChildState; - class Parent extends React.Component { - state = {x: 0}; - childRef = React.createRef(); + function Parent() { + const [state, _setState] = React.useState(0); + parentState = state; + setParentState = _setState; - componentDidUpdate() { - parentUpdateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Parent Commit'); + }); - render() { - return ( -
- -
- ); - } + return ( +
+ +
+ ); } - let childUpdateCount = 0; + function Child({prop}) { + const [state, _setState] = React.useState(0); + childState = state; + setChildState = _setState; - class Child extends React.Component { - state = {y: 0}; - - componentDidUpdate() { - childUpdateCount++; - } + React.useLayoutEffect(() => { + Scheduler.log('Child Commit'); + }); - render() { - return
{this.props.x + this.state.y}
; - } + return ( +
{ + childRef = ref; + }}> + {prop} {state} +
+ ); } - const instance = ReactTestUtils.renderIntoDocument(); - const child = instance.childRef.current; - expect(instance.state.x).toBe(0); - expect(child.state.y).toBe(0); - - ReactDOM.unstable_batchedUpdates(function () { - child.setState({y: 2}); - instance.setState({x: 1}); - expect(instance.state.x).toBe(0); - expect(child.state.y).toBe(0); - expect(parentUpdateCount).toBe(0); - expect(childUpdateCount).toBe(0); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); }); - expect(instance.state.x).toBe(1); - expect(child.state.y).toBe(2); - expect(parentUpdateCount).toBe(1); + assertLog(['Child Commit', 'Parent Commit']); + expect(childRef.textContent).toBe('0 0'); + + await act(() => { + // Child update first. + setChildState(2); + setParentState(1); + expect(parentState).toBe(0); + expect(childState).toBe(0); + expect(childRef.textContent).toBe('0 0'); + assertLog([]); + }); - // Batching reduces the number of updates here to 1. - expect(childUpdateCount).toBe(1); + expect(parentState).toBe(1); + expect(childState).toBe(2); + expect(childRef.textContent).toBe('1 2'); + assertLog(['Child Commit', 'Parent Commit']); }); - it('should support chained state updates', () => { - let updateCount = 0; - + it('should support chained state updates', async () => { + let instance; class Component extends React.Component { state = {x: 0}; + constructor(props) { + super(props); + instance = this; + } componentDidUpdate() { - updateCount++; + Scheduler.log('Update'); } render() { @@ -275,43 +332,55 @@ describe('ReactUpdates', () => { } } - const instance = ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + expect(instance.state.x).toBe(0); + expect(container.firstChild.textContent).toBe('0'); let innerCallbackRun = false; - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { instance.setState({x: 1}, function () { instance.setState({x: 2}, function () { - expect(this).toBe(instance); innerCallbackRun = true; expect(instance.state.x).toBe(2); - expect(updateCount).toBe(2); + expect(container.firstChild.textContent).toBe('2'); + assertLog(['Update']); }); expect(instance.state.x).toBe(1); - expect(updateCount).toBe(1); + expect(container.firstChild.textContent).toBe('1'); + assertLog(['Update']); }); expect(instance.state.x).toBe(0); - expect(updateCount).toBe(0); + expect(container.firstChild.textContent).toBe('0'); + assertLog([]); }); - expect(innerCallbackRun).toBeTruthy(); + assertLog([]); expect(instance.state.x).toBe(2); - expect(updateCount).toBe(2); + expect(innerCallbackRun).toBeTruthy(); + expect(container.firstChild.textContent).toBe('2'); }); - it('should batch forceUpdate together', () => { + it('should batch forceUpdate together', async () => { + let instance; let shouldUpdateCount = 0; - let updateCount = 0; - class Component extends React.Component { state = {x: 0}; + constructor(props) { + super(props); + instance = this; + } shouldComponentUpdate() { shouldUpdateCount++; } componentDidUpdate() { - updateCount++; + Scheduler.log('Update'); } render() { @@ -319,80 +388,82 @@ describe('ReactUpdates', () => { } } - const instance = ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + assertLog([]); expect(instance.state.x).toBe(0); - let callbacksRun = 0; - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { instance.setState({x: 1}, function () { - callbacksRun++; + Scheduler.log('callback'); }); instance.forceUpdate(function () { - callbacksRun++; + Scheduler.log('forceUpdate'); }); + assertLog([]); expect(instance.state.x).toBe(0); - expect(updateCount).toBe(0); + expect(container.firstChild.textContent).toBe('0'); }); - expect(callbacksRun).toBe(2); // shouldComponentUpdate shouldn't be called since we're forcing expect(shouldUpdateCount).toBe(0); + assertLog(['Update', 'callback', 'forceUpdate']); expect(instance.state.x).toBe(1); - expect(updateCount).toBe(1); + expect(container.firstChild.textContent).toBe('1'); }); - it('should update children even if parent blocks updates', () => { - let parentRenderCount = 0; - let childRenderCount = 0; - + it('should update children even if parent blocks updates', async () => { + let instance; class Parent extends React.Component { childRef = React.createRef(); + constructor(props) { + super(props); + instance = this; + } shouldComponentUpdate() { return false; } render() { - parentRenderCount++; + Scheduler.log('Parent render'); return ; } } class Child extends React.Component { render() { - childRenderCount++; + Scheduler.log('Child render'); return
; } } - expect(parentRenderCount).toBe(0); - expect(childRenderCount).toBe(0); - - let instance = ; - instance = ReactTestUtils.renderIntoDocument(instance); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - expect(parentRenderCount).toBe(1); - expect(childRenderCount).toBe(1); + assertLog(['Parent render', 'Child render']); - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { instance.setState({x: 1}); }); - expect(parentRenderCount).toBe(1); - expect(childRenderCount).toBe(1); + assertLog([]); - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { instance.childRef.current.setState({x: 1}); }); - expect(parentRenderCount).toBe(1); - expect(childRenderCount).toBe(2); + assertLog(['Child render']); }); - it('should not reconcile children passed via props', () => { - let numMiddleRenders = 0; - let numBottomRenders = 0; - + it('should not reconcile children passed via props', async () => { class Top extends React.Component { render() { return ( @@ -409,26 +480,31 @@ describe('ReactUpdates', () => { } render() { - numMiddleRenders++; + Scheduler.log('Middle'); return React.Children.only(this.props.children); } } class Bottom extends React.Component { render() { - numBottomRenders++; + Scheduler.log('Bottom'); return null; } } - ReactTestUtils.renderIntoDocument(); - expect(numMiddleRenders).toBe(2); - expect(numBottomRenders).toBe(1); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + assertLog(['Middle', 'Bottom', 'Middle']); }); - it('should flow updates correctly', () => { + it('should flow updates correctly', async () => { let willUpdates = []; let didUpdates = []; + let instance; const UpdateLoggingMixin = { UNSAFE_componentWillUpdate: function () { @@ -482,7 +558,10 @@ describe('ReactUpdates', () => { class App extends React.Component { switcherRef = React.createRef(); childRef = React.createRef(); - + constructor(props) { + super(props); + instance = this; + } render() { return ( @@ -493,8 +572,10 @@ describe('ReactUpdates', () => { } Object.assign(App.prototype, UpdateLoggingMixin); - let root = ; - root = ReactTestUtils.renderIntoDocument(root); + const container = document.createElement('div'); + await act(() => { + ReactDOMClient.createRoot(container).render(); + }); function expectUpdates(desiredWillUpdates, desiredDidUpdates) { let i; @@ -512,10 +593,14 @@ describe('ReactUpdates', () => { c.setState({x: 1}); } - function testUpdates(components, desiredWillUpdates, desiredDidUpdates) { + async function testUpdates( + components, + desiredWillUpdates, + desiredDidUpdates, + ) { let i; - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { for (i = 0; i < components.length; i++) { triggerUpdate(components[i]); } @@ -525,7 +610,7 @@ describe('ReactUpdates', () => { // Try them in reverse order - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { for (i = components.length - 1; i >= 0; i--) { triggerUpdate(components[i]); } @@ -533,42 +618,48 @@ describe('ReactUpdates', () => { expectUpdates(desiredWillUpdates, desiredDidUpdates); } - testUpdates( - [root.switcherRef.current.boxRef.current, root.switcherRef.current], + await testUpdates( + [ + instance.switcherRef.current.boxRef.current, + instance.switcherRef.current, + ], // Owner-child relationships have inverse will and did ['Switcher', 'Box'], ['Box', 'Switcher'], ); - testUpdates( - [root.childRef.current, root.switcherRef.current.boxRef.current], + await testUpdates( + [instance.childRef.current, instance.switcherRef.current.boxRef.current], // Not owner-child so reconcile independently ['Box', 'Child'], ['Box', 'Child'], ); - testUpdates( - [root.childRef.current, root.switcherRef.current], + await testUpdates( + [instance.childRef.current, instance.switcherRef.current], // Switcher owns Box and Child, Box does not own Child ['Switcher', 'Box', 'Child'], ['Box', 'Switcher', 'Child'], ); }); - it('should queue mount-ready handlers across different roots', () => { + it('should queue mount-ready handlers across different roots', async () => { // We'll define two components A and B, then update both of them. When A's // componentDidUpdate handlers is called, B's DOM should already have been // updated. const bContainer = document.createElement('div'); - + let a; let b; let aUpdated = false; class A extends React.Component { state = {x: 0}; - + constructor(props) { + super(props); + a = this; + } componentDidUpdate() { expect(ReactDOM.findDOMNode(b).textContent).toBe('B1'); aUpdated = true; @@ -576,7 +667,6 @@ describe('ReactUpdates', () => { render() { let portal = null; - // If we're using Fiber, we use Portals instead to achieve this. portal = ReactDOM.createPortal( (b = n)} />, bContainer); return (
@@ -595,8 +685,13 @@ describe('ReactUpdates', () => { } } - const a = ReactTestUtils.renderIntoDocument(); - ReactDOM.unstable_batchedUpdates(function () { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + await act(() => { a.setState({x: 1}); b.setState({x: 1}); }); @@ -604,13 +699,16 @@ describe('ReactUpdates', () => { expect(aUpdated).toBe(true); }); - it('should flush updates in the correct order', () => { + it('should flush updates in the correct order', async () => { const updates = []; - + let instance; class Outer extends React.Component { state = {x: 0}; innerRef = React.createRef(); - + constructor(props) { + super(props); + instance = this; + } render() { updates.push('Outer-render-' + this.state.x); return ( @@ -643,14 +741,20 @@ describe('ReactUpdates', () => { } } - const instance = ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - updates.push('Outer-setState-1'); - instance.setState({x: 1}, function () { - updates.push('Outer-callback-1'); - updates.push('Outer-setState-2'); - instance.setState({x: 2}, function () { - updates.push('Outer-callback-2'); + await act(() => { + updates.push('Outer-setState-1'); + instance.setState({x: 1}, function () { + updates.push('Outer-callback-1'); + updates.push('Outer-setState-2'); + instance.setState({x: 2}, function () { + updates.push('Outer-callback-2'); + }); }); }); @@ -686,7 +790,7 @@ describe('ReactUpdates', () => { /* eslint-enable indent */ }); - it('should flush updates in the correct order across roots', () => { + it('should flush updates in the correct order across roots', async () => { const instances = []; const updates = []; @@ -699,22 +803,26 @@ describe('ReactUpdates', () => { componentDidMount() { instances.push(this); if (this.props.depth < this.props.count) { - ReactDOM.render( + const root = ReactDOMClient.createRoot(ReactDOM.findDOMNode(this)); + root.render( , - ReactDOM.findDOMNode(this), ); } } } - ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); expect(updates).toEqual([0, 1, 2]); - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { // Simulate update on each component from top to bottom. instances.forEach(function (instance) { instance.forceUpdate(); @@ -777,7 +885,7 @@ describe('ReactUpdates', () => { expect(ReactDOM.findDOMNode(x).textContent).toBe('1'); }); - it('should queue updates from during mount', () => { + it('should queue updates from during mount', async () => { // See https://github.com/facebook/react/issues/1353 let a; @@ -803,8 +911,11 @@ describe('ReactUpdates', () => { } } - ReactDOM.unstable_batchedUpdates(function () { - ReactTestUtils.renderIntoDocument( + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render(
@@ -812,13 +923,10 @@ describe('ReactUpdates', () => { ); }); - expect(a.state.x).toBe(1); - expect(ReactDOM.findDOMNode(a).textContent).toBe('A1'); + expect(container.firstChild.textContent).toBe('A1'); }); - it('calls componentWillReceiveProps setState callback properly', () => { - let callbackCount = 0; - + it('calls componentWillReceiveProps setState callback properly', async () => { class A extends React.Component { state = {x: this.props.x}; @@ -827,7 +935,7 @@ describe('ReactUpdates', () => { this.setState({x: newX}, function () { // State should have updated by the time this callback gets called expect(this.state.x).toBe(newX); - callbackCount++; + Scheduler.log('Callback'); }); } @@ -837,13 +945,22 @@ describe('ReactUpdates', () => { } const container = document.createElement('div'); - ReactDOM.render(, container); - ReactDOM.render(, container); - expect(callbackCount).toBe(1); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + assertLog([]); + + // Needs to be a separate act, or it will be batched. + await act(() => { + root.render(); + }); + + assertLog(['Callback']); }); - it('does not call render after a component as been deleted', () => { - let renderCount = 0; + it('does not call render after a component as been deleted', async () => { + let componentA = null; let componentB = null; class B extends React.Component { @@ -854,7 +971,7 @@ describe('ReactUpdates', () => { } render() { - renderCount++; + Scheduler.log('B'); return
; } } @@ -862,21 +979,29 @@ describe('ReactUpdates', () => { class A extends React.Component { state = {showB: true}; + componentDidMount() { + componentA = this; + } render() { return this.state.showB ? :
; } } - const component = ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + assertLog(['B']); - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { // B will have scheduled an update but the batching should ensure that its // update never fires. componentB.setState({updates: 1}); - component.setState({showB: false}); + componentA.setState({showB: false}); }); - expect(renderCount).toBe(1); + assertLog([]); }); it('throws in setState if the update callback is not a function', () => { @@ -965,10 +1090,14 @@ describe('ReactUpdates', () => { ); }); - it('does not update one component twice in a batch (#2410)', () => { + it('does not update one component twice in a batch (#2410)', async () => { + let parent; class Parent extends React.Component { childRef = React.createRef(); + componentDidMount() { + parent = this; + } getChild = () => { return this.childRef.current; }; @@ -1009,15 +1138,22 @@ describe('ReactUpdates', () => { } } - const parent = ReactTestUtils.renderIntoDocument(); + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + const child = parent.getChild(); - ReactDOM.unstable_batchedUpdates(function () { + await act(() => { parent.forceUpdate(); child.forceUpdate(); }); + + expect.assertions(6); }); - it('does not update one component twice in a batch (#6371)', () => { + it('does not update one component twice in a batch (#6371)', async () => { let callbacks = []; function emitChange() { callbacks.forEach(c => c()); @@ -1064,34 +1200,23 @@ describe('ReactUpdates', () => { } } - ReactDOM.render(, document.createElement('div')); - }); - - it('unstable_batchedUpdates should return value from a callback', () => { - const result = ReactDOM.unstable_batchedUpdates(function () { - return 42; + const root = ReactDOMClient.createRoot(document.createElement('div')); + await act(() => { + root.render(); }); - expect(result).toEqual(42); - }); - it('unmounts and remounts a root in the same batch', () => { - const container = document.createElement('div'); - ReactDOM.render(a, container); - ReactDOM.unstable_batchedUpdates(function () { - ReactDOM.unmountComponentAtNode(container); - ReactDOM.render(b, container); - }); - expect(container.textContent).toBe('b'); + // Error should not be thrown. + expect(true).toBe(true); }); - it('handles reentrant mounting in synchronous mode', () => { - let mounts = 0; + it('handles reentrant mounting in synchronous mode', async () => { + let onChangeCalled = false; class Editor extends React.Component { render() { return
{this.props.text}
; } componentDidMount() { - mounts++; + Scheduler.log('Mount'); // This should be called only once but we guard just in case. if (!this.props.rendered) { this.props.onChange({rendered: true}); @@ -1100,163 +1225,57 @@ describe('ReactUpdates', () => { } const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); function render() { - ReactDOM.render( + root.render( { + onChangeCalled = true; props = {...props, ...newProps}; render(); }} {...props} />, - container, ); } let props = {text: 'hello', rendered: false}; - render(); + await act(() => { + render(); + }); + assertLog(['Mount']); props = {...props, text: 'goodbye'}; - render(); + await act(() => { + render(); + }); + + assertLog([]); expect(container.textContent).toBe('goodbye'); - expect(mounts).toBe(1); + expect(onChangeCalled).toBeTruthy(); }); - it('mounts and unmounts are sync even in a batch', () => { - const ops = []; + it('mounts and unmounts are batched', async () => { const container = document.createElement('div'); - ReactDOM.unstable_batchedUpdates(() => { - ReactDOM.render(
Hello
, container); - ops.push(container.textContent); - ReactDOM.unmountComponentAtNode(container); - ops.push(container.textContent); - }); - expect(ops).toEqual(['Hello', '']); - }); - - it( - 'in legacy mode, updates in componentWillUpdate and componentDidUpdate ' + - 'should both flush in the immediately subsequent commit', - () => { - const ops = []; - class Foo extends React.Component { - state = {a: false, b: false}; - UNSAFE_componentWillUpdate(_, nextState) { - if (!nextState.a) { - this.setState({a: true}); - } - } - componentDidUpdate() { - ops.push('Foo updated'); - if (!this.state.b) { - this.setState({b: true}); - } - } - render() { - ops.push(`a: ${this.state.a}, b: ${this.state.b}`); - return null; - } - } - - const container = document.createElement('div'); - // Mount - ReactDOM.render(, container); - // Root update - ReactDOM.render(, container); - expect(ops).toEqual([ - // Mount - 'a: false, b: false', - // Root update - 'a: false, b: false', - 'Foo updated', - // Subsequent update (both a and b should have flushed) - 'a: true, b: true', - 'Foo updated', - // There should not be any additional updates - ]); - }, - ); - - it( - 'in legacy mode, updates in componentWillUpdate and componentDidUpdate ' + - '(on a sibling) should both flush in the immediately subsequent commit', - () => { - const ops = []; - class Foo extends React.Component { - state = {a: false}; - UNSAFE_componentWillUpdate(_, nextState) { - if (!nextState.a) { - this.setState({a: true}); - } - } - componentDidUpdate() { - ops.push('Foo updated'); - } - render() { - ops.push(`a: ${this.state.a}`); - return null; - } - } + const root = ReactDOMClient.createRoot(container); - class Bar extends React.Component { - state = {b: false}; - componentDidUpdate() { - ops.push('Bar updated'); - if (!this.state.b) { - this.setState({b: true}); - } - } - render() { - ops.push(`b: ${this.state.b}`); - return null; - } - } + await act(() => { + root.render(
Hello
); + expect(container.textContent).toBe(''); + root.unmount(container); + expect(container.textContent).toBe(''); + }); - const container = document.createElement('div'); - // Mount - ReactDOM.render( -
- - -
, - container, - ); - // Root update - ReactDOM.render( -
- - -
, - container, - ); - expect(ops).toEqual([ - // Mount - 'a: false', - 'b: false', - // Root update - 'a: false', - 'b: false', - 'Foo updated', - 'Bar updated', - // Subsequent update (both a and b should have flushed) - 'a: true', - 'b: true', - 'Foo updated', - 'Bar updated', - // There should not be any additional updates - ]); - }, - ); - - it('uses correct base state for setState inside render phase', () => { - const ops = []; + expect(container.textContent).toBe(''); + }); + it('uses correct base state for setState inside render phase', async () => { class Foo extends React.Component { state = {step: 0}; render() { const memoizedStep = this.state.step; this.setState(baseState => { const baseStep = baseState.step; - ops.push(`base: ${baseStep}, memoized: ${memoizedStep}`); + Scheduler.log(`base: ${baseStep}, memoized: ${memoizedStep}`); return baseStep === 0 ? {step: 1} : null; }); return null; @@ -1264,48 +1283,54 @@ describe('ReactUpdates', () => { } const container = document.createElement('div'); - expect(() => ReactDOM.render(, container)).toErrorDev( - 'Cannot update during an existing state transition', - ); - expect(ops).toEqual(['base: 0, memoized: 0', 'base: 1, memoized: 1']); + const root = ReactDOMClient.createRoot(container); + await expect(async () => { + await act(() => { + root.render(); + }); + }).toErrorDev('Cannot update during an existing state transition'); + + assertLog(['base: 0, memoized: 0', 'base: 1, memoized: 1']); }); - it('does not re-render if state update is null', () => { + it('does not re-render if state update is null', async () => { const container = document.createElement('div'); let instance; - let ops = []; class Foo extends React.Component { render() { instance = this; - ops.push('render'); + Scheduler.log('render'); return
; } } - ReactDOM.render(, container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); - ops = []; - instance.setState(() => null); - expect(ops).toEqual([]); + assertLog(['render']); + await act(() => { + instance.setState(() => null); + }); + assertLog([]); }); - // Will change once we switch to async by default - it('synchronously renders hidden subtrees', () => { + it('synchronously renders hidden subtrees', async () => { const container = document.createElement('div'); - let ops = []; function Baz() { - ops.push('Baz'); + Scheduler.log('Baz'); return null; } function Bar() { - ops.push('Bar'); + Scheduler.log('Bar'); return null; } function Foo() { - ops.push('Foo'); + Scheduler.log('Foo'); return (