From 615df434bbecc357d47ce26e3d6362edec477050 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 12 Mar 2020 10:17:02 -0700 Subject: [PATCH] Remove manual feature flag overrides (#18291) Tests now run against both versions of these flags, using the test-www-variant command. So we can remove the manual overrides. --- ...eactHooksWithNoopRenderer-test.internal.js | 6001 ++++++++--------- ...tSuspenseWithNoopRenderer-test.internal.js | 5747 ++++++++-------- 2 files changed, 5776 insertions(+), 5972 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 7682ebc1fa..7a403a85b2 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -33,3177 +33,3038 @@ let useDeferredValue; let forwardRef; let memo; let act; +let deferPassiveEffectCleanupDuringUnmount; +let runAllPassiveEffectDestroysBeforeCreates; -function loadModules({ - deferPassiveEffectCleanupDuringUnmount, - runAllPassiveEffectDestroysBeforeCreates, -}) { - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.enableSchedulerTracing = true; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; - ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates; - ReactFeatureFlags.enableProfilerTimer = true; - React = require('react'); - ReactNoop = require('react-noop-renderer'); - Scheduler = require('scheduler'); - SchedulerTracing = require('scheduler/tracing'); - ReactCache = require('react-cache'); - useState = React.useState; - useReducer = React.useReducer; - useEffect = React.useEffect; - useLayoutEffect = React.useLayoutEffect; - useCallback = React.useCallback; - useMemo = React.useMemo; - useRef = React.useRef; - useImperativeHandle = React.useImperativeHandle; - forwardRef = React.forwardRef; - memo = React.memo; - useTransition = React.useTransition; - useDeferredValue = React.useDeferredValue; - Suspense = React.Suspense; - act = ReactNoop.act; -} +describe('ReactHooksWithNoopRenderer', () => { + beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); -[true, false].forEach(deferPassiveEffectCleanupDuringUnmount => { - [true, false].forEach(runAllPassiveEffectDestroysBeforeCreates => { - describe(`ReactHooksWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { - beforeEach(() => { - jest.resetModules(); - jest.useFakeTimers(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableSchedulerTracing = true; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + ReactFeatureFlags.enableProfilerTimer = true; + deferPassiveEffectCleanupDuringUnmount = + ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount; + runAllPassiveEffectDestroysBeforeCreates = + ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates; - loadModules({ - deferPassiveEffectCleanupDuringUnmount, - runAllPassiveEffectDestroysBeforeCreates, + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + SchedulerTracing = require('scheduler/tracing'); + ReactCache = require('react-cache'); + useState = React.useState; + useReducer = React.useReducer; + useEffect = React.useEffect; + useLayoutEffect = React.useLayoutEffect; + useCallback = React.useCallback; + useMemo = React.useMemo; + useRef = React.useRef; + useImperativeHandle = React.useImperativeHandle; + forwardRef = React.forwardRef; + memo = React.memo; + useTransition = React.useTransition; + useDeferredValue = React.useDeferredValue; + Suspense = React.Suspense; + act = ReactNoop.act; + + TextResource = ReactCache.unstable_createResource( + ([text, ms = 0]) => { + return new Promise((resolve, reject) => + setTimeout(() => { + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); + resolve(text); + }, ms), + ); + }, + ([text, ms]) => text, + ); + }); + + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; + } + + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; + } + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; + } + + function AsyncText(props) { + const text = props.text; + try { + TextResource.read([props.text, props.ms]); + Scheduler.unstable_yieldValue(text); + return ; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; + } + } + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); + } + + it('resumes after an interruption', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + + // Initial mount + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Schedule some updates + ReactNoop.batchedUpdates(() => { + counter.current.updateCount(1); + counter.current.updateCount(count => count + 10); + }); + + // Partially flush without committing + expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Interrupt with a high priority update + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded(['Total: 0']); + + // Resume rendering + expect(Scheduler).toFlushAndYield(['Total: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); + }); + + it('throws inside class components', () => { + class BadCounter extends React.Component { + render() { + const [count] = useState(0); + return ; + } + } + ReactNoop.render(); + + expect(Scheduler).toFlushAndThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props, ref) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); + + it('throws inside module-style components', () => { + function Counter() { + return { + render() { + const [count] = useState(0); + return ; + }, + }; + } + ReactNoop.render(); + expect(() => + expect(Scheduler).toFlushAndThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen ' + + 'for one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ), + ).toErrorDev( + 'Warning: The component appears to be a function component that returns a class instance. ' + + 'Change Counter to a class that extends React.Component instead. ' + + "If you can't use a class try assigning the prototype on the function as a workaround. " + + '`Counter.prototype = React.Component.prototype`. ' + + "Don't use an arrow function since it cannot be called with `new` by React.", + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); + + it('throws when called outside the render phase', () => { + expect(() => useState(0)).toThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ); + }); + + describe('useState', () => { + it('simple mount and update', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => counter.current.updateCount(1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + act(() => counter.current.updateCount(count => count + 10)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + }); + + it('lazy state initializer', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(() => { + Scheduler.unstable_yieldValue('getInitialState'); + return props.initialState; }); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['getInitialState', 'Count: 42']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); - TextResource = ReactCache.unstable_createResource( - ([text, ms = 0]) => { - return new Promise((resolve, reject) => - setTimeout(() => { - Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); - resolve(text); - }, ms), - ); - }, - ([text, ms]) => text, + act(() => counter.current.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + }); + + it('multiple states', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + const [label, updateLabel] = useState('Count'); + useImperativeHandle(ref, () => ({updateCount, updateLabel})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => counter.current.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); + + act(() => counter.current.updateLabel('Total')); + expect(Scheduler).toHaveYielded(['Total: 7']); + }); + + it('returns the same updater function every time', () => { + let updaters = []; + function Counter() { + const [count, updateCount] = useState(0); + updaters.push(updateCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => updaters[0](1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + act(() => updaters[0](count => count + 10)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + }); + + it('warns on set after unmount', () => { + let _updateCount; + function Counter(props, ref) { + const [, updateCount] = useState(0); + _updateCount = updateCount; + return null; + } + + ReactNoop.render(); + expect(Scheduler).toFlushWithoutYielding(); + ReactNoop.render(null); + expect(Scheduler).toFlushWithoutYielding(); + expect(() => act(() => _updateCount(1))).toErrorDev( + "Warning: Can't perform a React state update on an unmounted " + + 'component. This is a no-op, but it indicates a memory leak in your ' + + 'application. To fix, cancel all subscriptions and asynchronous ' + + 'tasks in a useEffect cleanup function.\n' + + ' in Counter (at **)', + ); + }); + + it('works with memo', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + return ; + } + Counter = memo(Counter); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => _updateCount(1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); + + describe('updates during the render phase', () => { + it('restarts the render function and applies the new updates on top', () => { + function ScrollView({row: newRow}) { + let [isScrollingDown, setIsScrollingDown] = useState(false); + let [row, setRow] = useState(null); + + if (row !== newRow) { + // Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row !== null && newRow > row); + setRow(newRow); + } + + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + }); + + it('warns about render phase update on a different component', async () => { + let setStep; + function Foo() { + const [step, _setStep] = useState(0); + setStep = _setStep; + return ; + } + + function Bar({triggerUpdate}) { + if (triggerUpdate) { + setStep(1); + } + return ; + } + + const root = ReactNoop.createRoot(); + + await ReactNoop.act(async () => { + root.render( + <> + + + , ); }); + expect(Scheduler).toHaveYielded(['Foo [0]', 'Bar']); - function span(prop) { - return {type: 'span', hidden: false, children: [], prop}; - } - - function hiddenSpan(prop) { - return {type: 'span', children: [], prop, hidden: true}; - } - - function Text(props) { - Scheduler.unstable_yieldValue(props.text); - return ; - } - - function AsyncText(props) { - const text = props.text; - try { - TextResource.read([props.text, props.ms]); - Scheduler.unstable_yieldValue(text); - return ; - } catch (promise) { - if (typeof promise.then === 'function') { - Scheduler.unstable_yieldValue(`Suspend! [${text}]`); - } else { - Scheduler.unstable_yieldValue(`Error! [${text}]`); - } - throw promise; - } - } - - function advanceTimers(ms) { - // Note: This advances Jest's virtual time but not React's. Use - // ReactNoop.expire for that. - if (typeof ms !== 'number') { - throw new Error('Must specify ms'); - } - jest.advanceTimersByTime(ms); - // Wait until the end of the current tick - // We cannot use a timer since we're faking them - return Promise.resolve().then(() => {}); - } - - it('resumes after an interruption', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - - // Initial mount - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - // Schedule some updates - ReactNoop.batchedUpdates(() => { - counter.current.updateCount(1); - counter.current.updateCount(count => count + 10); - }); - - // Partially flush without committing - expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - // Interrupt with a high priority update - ReactNoop.flushSync(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded(['Total: 0']); - - // Resume rendering - expect(Scheduler).toFlushAndYield(['Total: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); - }); - - it('throws inside class components', () => { - class BadCounter extends React.Component { - render() { - const [count] = useState(0); - return ; - } - } - ReactNoop.render(); - - expect(Scheduler).toFlushAndThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + - ' one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + // Bar will update Foo during its render phase. React should warn. + await ReactNoop.act(async () => { + root.render( + <> + + + , ); - - // Confirm that a subsequent hook works properly. - function GoodCounter(props, ref) { - const [count] = useState(props.initialCount); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([10]); - }); - - it('throws inside module-style components', () => { - function Counter() { - return { - render() { - const [count] = useState(0); - return ; - }, - }; - } - ReactNoop.render(); expect(() => - expect(Scheduler).toFlushAndThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen ' + - 'for one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', - ), - ).toErrorDev( - 'Warning: The component appears to be a function component that returns a class instance. ' + - 'Change Counter to a class that extends React.Component instead. ' + - "If you can't use a class try assigning the prototype on the function as a workaround. " + - '`Counter.prototype = React.Component.prototype`. ' + - "Don't use an arrow function since it cannot be called with `new` by React.", - ); + expect(Scheduler).toFlushAndYield(['Foo [0]', 'Bar', 'Foo [1]']), + ).toErrorDev([ + 'Cannot update a component from inside the function body of a ' + + 'different component.', + ]); + }); + }); - // Confirm that a subsequent hook works properly. - function GoodCounter(props) { - const [count] = useState(props.initialCount); - return ; + it('keeps restarting until there are no more new updates', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([10]); - }); + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } - it('throws when called outside the render phase', () => { - expect(() => useState(0)).toThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + - ' one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('updates multiple times within same render function', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 12) { + setCount(c => c + 1); + setCount(c => c + 1); + setCount(c => c + 1); + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // Should increase by three each time + 'Render: 0', + 'Render: 3', + 'Render: 6', + 'Render: 9', + 'Render: 12', + 12, + ]); + expect(ReactNoop.getChildren()).toEqual([span(12)]); + }); + + it('throws after too many iterations', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + setCount(count + 1); + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + }); + + it('works with useReducer', () => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter({row: newRow}) { + let [count, dispatch] = useReducer(reducer, 0); + if (count < 3) { + dispatch('increment'); + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('uses reducer passed at time of render, not time of dispatch', () => { + // This test is a bit contrived but it demonstrates a subtle edge case. + + // Reducer A increments by 1. Reducer B increments by 10. + function reducerA(state, action) { + switch (action) { + case 'increment': + return state + 1; + case 'reset': + return 0; + } + } + function reducerB(state, action) { + switch (action) { + case 'increment': + return state + 10; + case 'reset': + return 0; + } + } + + function Counter({row: newRow}, ref) { + let [reducer, setReducer] = useState(() => reducerA); + let [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + if (count < 20) { + dispatch('increment'); + // Swap reducers each time we increment + if (reducer === reducerA) { + setReducer(() => reducerB); + } else { + setReducer(() => reducerA); + } + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // The count should increase by alternating amounts of 10 and 1 + // until we reach 21. + 'Render: 0', + 'Render: 10', + 'Render: 11', + 'Render: 21', + 21, + ]); + expect(ReactNoop.getChildren()).toEqual([span(21)]); + + // Test that it works on update, too. This time the log is a bit different + // because we started with reducerB instead of reducerA. + ReactNoop.act(() => { + counter.current.dispatch('reset'); + }); + ReactNoop.render(); + expect(Scheduler).toHaveYielded([ + 'Render: 0', + 'Render: 1', + 'Render: 11', + 'Render: 12', + 'Render: 22', + 22, + ]); + expect(ReactNoop.getChildren()).toEqual([span(22)]); + }); + + it('discards render phase updates if something suspends', () => { + const thenable = {then() {}}; + function Foo({signal}) { + return ( + + + ); + } + + function Bar({signal: newSignal}) { + let [counter, setCounter] = useState(0); + let [signal, setSignal] = useState(true); + + // Increment a counter every time the signal changes + if (signal !== newSignal) { + setCounter(c => c + 1); + setSignal(newSignal); + if (counter === 0) { + // We're suspending during a render that includes render phase + // updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue('Suspend!'); + throw thenable; + } + } + + return ; + } + + const root = ReactNoop.createRoot(); + root.render(); + + expect(Scheduler).toFlushAndYield([0]); + expect(root).toMatchRenderedOutput(); + + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + expect(root).toMatchRenderedOutput(); + + // Rendering again should suspend again. + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + }); + + it('discards render phase updates if something suspends, but not other updates in the same component', async () => { + const thenable = {then() {}}; + function Foo({signal}) { + return ( + + + + ); + } + + let setLabel; + function Bar({signal: newSignal}) { + let [counter, setCounter] = useState(0); + + if (counter === 1) { + // We're suspending during a render that includes render phase + // updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue('Suspend!'); + throw thenable; + } + + let [signal, setSignal] = useState(true); + + // Increment a counter every time the signal changes + if (signal !== newSignal) { + setCounter(c => c + 1); + setSignal(newSignal); + } + + let [label, _setLabel] = useState('A'); + setLabel = _setLabel; + + return ; + } + + const root = ReactNoop.createRoot(); + root.render(); + + expect(Scheduler).toFlushAndYield(['A:0']); + expect(root).toMatchRenderedOutput(); + + await ReactNoop.act(async () => { + root.render(); + setLabel('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend!']); + expect(root).toMatchRenderedOutput(); + + // Rendering again should suspend again. + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + + // Flip the signal back to "cancel" the update. However, the update to + // label should still proceed. It shouldn't have been dropped. + root.render(); + expect(Scheduler).toFlushAndYield(['B:0']); + expect(root).toMatchRenderedOutput(); + }); + + // TODO: This should probably warn + it.experimental('calling startTransition inside render phase', async () => { + let startTransition; + function App() { + let [counter, setCounter] = useState(0); + let [_startTransition] = useTransition(); + startTransition = _startTransition; + + if (counter === 0) { + startTransition(() => { + setCounter(c => c + 1); + }); + } + + return ; + } + + const root = ReactNoop.createRoot(); + root.render(); + expect(Scheduler).toFlushAndYield([1]); + expect(root).toMatchRenderedOutput(); + }); + }); + + describe('useReducer', () => { + it('simple mount and update', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => counter.current.dispatch(INCREMENT)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + act(() => { + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); }); - describe('useState', () => { - it('simple mount and update', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(Scheduler).toHaveYielded(['Count: -2']); + expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); + }); - act(() => counter.current.updateCount(1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + it('lazy init', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; - act(() => counter.current.updateCount(count => count + 10)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, props, p => { + Scheduler.unstable_yieldValue('Init'); + return p.initialCount; }); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Init', 'Count: 10']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); - it('lazy state initializer', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(() => { - Scheduler.unstable_yieldValue('getInitialState'); - return props.initialState; - }); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['getInitialState', 'Count: 42']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); + act(() => counter.current.dispatch(INCREMENT)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - act(() => counter.current.updateCount(7)); - expect(Scheduler).toHaveYielded(['Count: 7']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + act(() => { + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + }); + + expect(Scheduler).toHaveYielded(['Count: 8']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); + }); + + // Regression test for https://github.com/facebook/react/issues/14360 + it('handles dispatches with mixed priorities', () => { + const INCREMENT = 'INCREMENT'; + + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } + + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.batchedUpdates(() => { + counter.current.dispatch(INCREMENT); + counter.current.dispatch(INCREMENT); + counter.current.dispatch(INCREMENT); + }); + + ReactNoop.flushSync(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + expect(Scheduler).toFlushAndYield(['Count: 4']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 4')]); + }); + }); + + describe('useEffect', () => { + it('simple mount and update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield(['Passive effect [0]']); + }); - it('multiple states', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - const [label, updateLabel] = useState('Count'); - useImperativeHandle(ref, () => ({updateCount, updateLabel})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield(['Passive effect [1]']); + }); + }); - act(() => counter.current.updateCount(7)); - expect(Scheduler).toHaveYielded(['Count: 7']); - - act(() => counter.current.updateLabel('Total')); - expect(Scheduler).toHaveYielded(['Total: 7']); + it('flushes passive effects even with sibling deletions', () => { + function LayoutEffect(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`Layout effect`); }); + return ; + } + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Passive effect`); + }, []); + return ; + } + let passive = ; + act(() => { + ReactNoop.render([, passive]); + expect(Scheduler).toFlushAndYieldThrough([ + 'Layout', + 'Passive', + 'Layout effect', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Layout'), + span('Passive'), + ]); + // Destroying the first child shouldn't prevent the passive effect from + // being executed + ReactNoop.render([passive]); + expect(Scheduler).toFlushAndYield(['Passive effect']); + expect(ReactNoop.getChildren()).toEqual([span('Passive')]); + }); + // exiting act calls flushPassiveEffects(), but there are none left to flush. + expect(Scheduler).toHaveYielded([]); + }); - it('returns the same updater function every time', () => { - let updaters = []; - function Counter() { - const [count, updateCount] = useState(0); - updaters.push(updateCount); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => updaters[0](1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - - act(() => updaters[0](count => count + 10)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - - expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + it('flushes passive effects even if siblings schedule an update', () => { + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect'); }); + return ; + } + function LayoutEffect(props) { + let [count, setCount] = useState(0); + useLayoutEffect(() => { + // Scheduling work shouldn't interfere with the queued passive effect + if (count === 0) { + setCount(1); + } + Scheduler.unstable_yieldValue('Layout effect ' + count); + }); + return ; + } - it('warns on set after unmount', () => { - let _updateCount; - function Counter(props, ref) { - const [, updateCount] = useState(0); - _updateCount = updateCount; + ReactNoop.render([, ]); + + act(() => { + expect(Scheduler).toFlushAndYield([ + 'Passive', + 'Layout', + 'Layout effect 0', + 'Passive effect', + 'Layout', + 'Layout effect 1', + ]); + }); + + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); + }); + + it('flushes passive effects even if siblings schedule a new root', () => { + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect'); + }, []); + return ; + } + function LayoutEffect(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Layout effect'); + // Scheduling work shouldn't interfere with the queued passive effect + ReactNoop.renderToRootWithID(, 'root2'); + }); + return ; + } + act(() => { + ReactNoop.render([, ]); + expect(Scheduler).toFlushAndYield([ + 'Passive', + 'Layout', + 'Layout effect', + 'Passive effect', + 'New Root', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); + }); + }); + + it( + 'flushes effects serially by flushing old effects before flushing ' + + "new ones, if they haven't already fired", + () => { + function getCommittedText() { + const children = ReactNoop.getChildren(); + if (children === null) { return null; } - - ReactNoop.render(); - expect(Scheduler).toFlushWithoutYielding(); - ReactNoop.render(null); - expect(Scheduler).toFlushWithoutYielding(); - expect(() => act(() => _updateCount(1))).toErrorDev( - "Warning: Can't perform a React state update on an unmounted " + - 'component. This is a no-op, but it indicates a memory leak in your ' + - 'application. To fix, cancel all subscriptions and asynchronous ' + - 'tasks in a useEffect cleanup function.\n' + - ' in Counter (at **)', - ); - }); - - it('works with memo', () => { - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - return ; - } - Counter = memo(Counter); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => _updateCount(1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - }); - - describe('updates during the render phase', () => { - it('restarts the render function and applies the new updates on top', () => { - function ScrollView({row: newRow}) { - let [isScrollingDown, setIsScrollingDown] = useState(false); - let [row, setRow] = useState(null); - - if (row !== newRow) { - // Row changed since last render. Update isScrollingDown. - setIsScrollingDown(row !== null && newRow > row); - setRow(newRow); - } - - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: false'), - ]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: true'), - ]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: true'), - ]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: true'), - ]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: false'), - ]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Scrolling down: false'), - ]); - }); - - it('warns about render phase update on a different component', async () => { - let setStep; - function Foo() { - const [step, _setStep] = useState(0); - setStep = _setStep; - return ; - } - - function Bar({triggerUpdate}) { - if (triggerUpdate) { - setStep(1); - } - return ; - } - - const root = ReactNoop.createRoot(); - - await ReactNoop.act(async () => { - root.render( - <> - - - , - ); - }); - expect(Scheduler).toHaveYielded(['Foo [0]', 'Bar']); - - // Bar will update Foo during its render phase. React should warn. - await ReactNoop.act(async () => { - root.render( - <> - - - , - ); - expect(() => - expect(Scheduler).toFlushAndYield(['Foo [0]', 'Bar', 'Foo [1]']), - ).toErrorDev([ - 'Cannot update a component from inside the function body of a ' + - 'different component.', - ]); - }); - }); - - it('keeps restarting until there are no more new updates', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - if (count < 3) { - setCount(count + 1); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); - - it('updates multiple times within same render function', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - if (count < 12) { - setCount(c => c + 1); - setCount(c => c + 1); - setCount(c => c + 1); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // Should increase by three each time - 'Render: 0', - 'Render: 3', - 'Render: 6', - 'Render: 9', - 'Render: 12', - 12, - ]); - expect(ReactNoop.getChildren()).toEqual([span(12)]); - }); - - it('throws after too many iterations', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - setCount(count + 1); - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndThrow( - 'Too many re-renders. React limits the number of renders to prevent ' + - 'an infinite loop.', - ); - }); - - it('works with useReducer', () => { - function reducer(state, action) { - return action === 'increment' ? state + 1 : state; - } - function Counter({row: newRow}) { - let [count, dispatch] = useReducer(reducer, 0); - if (count < 3) { - dispatch('increment'); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); - - it('uses reducer passed at time of render, not time of dispatch', () => { - // This test is a bit contrived but it demonstrates a subtle edge case. - - // Reducer A increments by 1. Reducer B increments by 10. - function reducerA(state, action) { - switch (action) { - case 'increment': - return state + 1; - case 'reset': - return 0; - } - } - function reducerB(state, action) { - switch (action) { - case 'increment': - return state + 10; - case 'reset': - return 0; - } - } - - function Counter({row: newRow}, ref) { - let [reducer, setReducer] = useState(() => reducerA); - let [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - if (count < 20) { - dispatch('increment'); - // Swap reducers each time we increment - if (reducer === reducerA) { - setReducer(() => reducerB); - } else { - setReducer(() => reducerA); - } - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // The count should increase by alternating amounts of 10 and 1 - // until we reach 21. - 'Render: 0', - 'Render: 10', - 'Render: 11', - 'Render: 21', - 21, - ]); - expect(ReactNoop.getChildren()).toEqual([span(21)]); - - // Test that it works on update, too. This time the log is a bit different - // because we started with reducerB instead of reducerA. - ReactNoop.act(() => { - counter.current.dispatch('reset'); - }); - ReactNoop.render(); - expect(Scheduler).toHaveYielded([ - 'Render: 0', - 'Render: 1', - 'Render: 11', - 'Render: 12', - 'Render: 22', - 22, - ]); - expect(ReactNoop.getChildren()).toEqual([span(22)]); - }); - - it('discards render phase updates if something suspends', () => { - const thenable = {then() {}}; - function Foo({signal}) { - return ( - - - - ); - } - - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); - let [signal, setSignal] = useState(true); - - // Increment a counter every time the signal changes - if (signal !== newSignal) { - setCounter(c => c + 1); - setSignal(newSignal); - if (counter === 0) { - // We're suspending during a render that includes render phase - // updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!'); - throw thenable; - } - } - - return ; - } - - const root = ReactNoop.createRoot(); - root.render(); - - expect(Scheduler).toFlushAndYield([0]); - expect(root).toMatchRenderedOutput(); - - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - expect(root).toMatchRenderedOutput(); - - // Rendering again should suspend again. - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - }); - - it('discards render phase updates if something suspends, but not other updates in the same component', async () => { - const thenable = {then() {}}; - function Foo({signal}) { - return ( - - - - ); - } - - let setLabel; - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); - - if (counter === 1) { - // We're suspending during a render that includes render phase - // updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!'); - throw thenable; - } - - let [signal, setSignal] = useState(true); - - // Increment a counter every time the signal changes - if (signal !== newSignal) { - setCounter(c => c + 1); - setSignal(newSignal); - } - - let [label, _setLabel] = useState('A'); - setLabel = _setLabel; - - return ; - } - - const root = ReactNoop.createRoot(); - root.render(); - - expect(Scheduler).toFlushAndYield(['A:0']); - expect(root).toMatchRenderedOutput(); - - await ReactNoop.act(async () => { - root.render(); - setLabel('B'); - }); - expect(Scheduler).toHaveYielded(['Suspend!']); - expect(root).toMatchRenderedOutput(); - - // Rendering again should suspend again. - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - - // Flip the signal back to "cancel" the update. However, the update to - // label should still proceed. It shouldn't have been dropped. - root.render(); - expect(Scheduler).toFlushAndYield(['B:0']); - expect(root).toMatchRenderedOutput(); - }); - - // TODO: This should probably warn - it.experimental( - 'calling startTransition inside render phase', - async () => { - let startTransition; - function App() { - let [counter, setCounter] = useState(0); - let [_startTransition] = useTransition(); - startTransition = _startTransition; - - if (counter === 0) { - startTransition(() => { - setCounter(c => c + 1); - }); - } - - return ; - } - - const root = ReactNoop.createRoot(); - root.render(); - expect(Scheduler).toFlushAndYield([1]); - expect(root).toMatchRenderedOutput(); - }, - ); - }); - - describe('useReducer', () => { - it('simple mount and update', () => { - const INCREMENT = 'INCREMENT'; - const DECREMENT = 'DECREMENT'; - - function reducer(state, action) { - switch (action) { - case 'INCREMENT': - return state + 1; - case 'DECREMENT': - return state - 1; - default: - return state; - } - } - - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => counter.current.dispatch(INCREMENT)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - act(() => { - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - }); - - expect(Scheduler).toHaveYielded(['Count: -2']); - expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); - }); - - it('lazy init', () => { - const INCREMENT = 'INCREMENT'; - const DECREMENT = 'DECREMENT'; - - function reducer(state, action) { - switch (action) { - case 'INCREMENT': - return state + 1; - case 'DECREMENT': - return state - 1; - default: - return state; - } - } - - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, props, p => { - Scheduler.unstable_yieldValue('Init'); - return p.initialCount; - }); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Init', 'Count: 10']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); - - act(() => counter.current.dispatch(INCREMENT)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - - act(() => { - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - }); - - expect(Scheduler).toHaveYielded(['Count: 8']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); - }); - - // Regression test for https://github.com/facebook/react/issues/14360 - it('handles dispatches with mixed priorities', () => { - const INCREMENT = 'INCREMENT'; - - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } - - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } - - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - ReactNoop.batchedUpdates(() => { - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - }); - - ReactNoop.flushSync(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - - expect(Scheduler).toFlushAndYield(['Count: 4']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 4')]); - }); - }); - - describe('useEffect', () => { - it('simple mount and update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield(['Passive effect [0]']); - }); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield(['Passive effect [1]']); - }); - }); - - it('flushes passive effects even with sibling deletions', () => { - function LayoutEffect(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue(`Layout effect`); - }); - return ; - } - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Passive effect`); - }, []); - return ; - } - let passive = ; - act(() => { - ReactNoop.render([, passive]); - expect(Scheduler).toFlushAndYieldThrough([ - 'Layout', - 'Passive', - 'Layout effect', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Layout'), - span('Passive'), - ]); - // Destroying the first child shouldn't prevent the passive effect from - // being executed - ReactNoop.render([passive]); - expect(Scheduler).toFlushAndYield(['Passive effect']); - expect(ReactNoop.getChildren()).toEqual([span('Passive')]); - }); - // exiting act calls flushPassiveEffects(), but there are none left to flush. - expect(Scheduler).toHaveYielded([]); - }); - - it('flushes passive effects even if siblings schedule an update', () => { - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Passive effect'); - }); - return ; - } - function LayoutEffect(props) { - let [count, setCount] = useState(0); - useLayoutEffect(() => { - // Scheduling work shouldn't interfere with the queued passive effect - if (count === 0) { - setCount(1); - } - Scheduler.unstable_yieldValue('Layout effect ' + count); - }); - return ; - } - - ReactNoop.render([ - , - , - ]); - - act(() => { - expect(Scheduler).toFlushAndYield([ - 'Passive', - 'Layout', - 'Layout effect 0', - 'Passive effect', - 'Layout', - 'Layout effect 1', - ]); - }); - - expect(ReactNoop.getChildren()).toEqual([ - span('Passive'), - span('Layout'), - ]); - }); - - it('flushes passive effects even if siblings schedule a new root', () => { - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Passive effect'); - }, []); - return ; - } - function LayoutEffect(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue('Layout effect'); - // Scheduling work shouldn't interfere with the queued passive effect - ReactNoop.renderToRootWithID(, 'root2'); - }); - return ; - } - act(() => { - ReactNoop.render([ - , - , - ]); - expect(Scheduler).toFlushAndYield([ - 'Passive', - 'Layout', - 'Layout effect', - 'Passive effect', - 'New Root', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Passive'), - span('Layout'), - ]); - }); - }); - - it( - 'flushes effects serially by flushing old effects before flushing ' + - "new ones, if they haven't already fired", - () => { - function getCommittedText() { - const children = ReactNoop.getChildren(); - if (children === null) { - return null; - } - return children[0].prop; - } - - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue( - `Committed state when effect was fired: ${getCommittedText()}`, - ); - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([0, 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span(0)]); - // Before the effects have a chance to flush, schedule another update - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - // The previous effect flushes before the reconciliation - 'Committed state when effect was fired: 0', - 1, - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(1)]); - }); - - expect(Scheduler).toHaveYielded([ - 'Committed state when effect was fired: 1', - ]); - }, - ); - - if ( - deferPassiveEffectCleanupDuringUnmount && - runAllPassiveEffectDestroysBeforeCreates - ) { - it('defers passive effect destroy functions during unmount', () => { - function Child({bar, foo}) { - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive bar create'); - return () => { - Scheduler.unstable_yieldValue('passive bar destroy'); - }; - }, [bar]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout bar create'); - return () => { - Scheduler.unstable_yieldValue('layout bar destroy'); - }; - }, [bar]); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive foo create'); - return () => { - Scheduler.unstable_yieldValue('passive foo destroy'); - }; - }, [foo]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout foo create'); - return () => { - Scheduler.unstable_yieldValue('layout foo destroy'); - }; - }, [foo]); - Scheduler.unstable_yieldValue('render'); - return null; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout bar create', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar create', - 'passive foo create', - ]); - }); - - // This update is exists to test an internal implementation detail: - // Effects without updating dependencies lose their layout/passive tag during an update. - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout foo destroy', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive foo destroy', - 'passive foo create', - ]); - }); - - // Unmount the component and verify that passive destroy functions are deferred until post-commit. - act(() => { - ReactNoop.render(null, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'layout bar destroy', - 'layout foo destroy', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar destroy', - 'passive foo destroy', - ]); - }); - }); - - it('does not warn about state updates for unmounted components with pending passive unmounts', () => { - let completePendingRequest = null; - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout create'); - return () => { - Scheduler.unstable_yieldValue('layout destroy'); - }; - }, []); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - return () => { - Scheduler.unstable_yieldValue('passive destroy'); - }; - }, []); - return didLoad; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'layout create', - 'Sync effect', - ]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['passive create']); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); - - // Simulate an XHR completing, which will cause a state update- - // but should not log a warning. - completePendingRequest(); - - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['passive destroy']); - }); - }); - - it('still warns about state updates for unmounted components with no pending passive unmounts', () => { - let completePendingRequest = null; - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - return () => { - Scheduler.unstable_yieldValue('layout destroy'); - }; - }, []); - return didLoad; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'layout create', - 'Sync effect', - ]); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); - - // Simulate an XHR completing. - expect(completePendingRequest).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component.", - ); - }); - }); - - it('still warns if there are pending passive unmount effects but not for the current fiber', () => { - let completePendingRequest = null; - function ComponentWithXHR() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('a:layout create'); - return () => { - Scheduler.unstable_yieldValue('a:layout destroy'); - }; - }, []); - React.useEffect(() => { - Scheduler.unstable_yieldValue('a:passive create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - }, []); - return didLoad; - } - - function ComponentWithPendingPassiveUnmount() { - React.useEffect(() => { - Scheduler.unstable_yieldValue('b:passive create'); - return () => { - Scheduler.unstable_yieldValue('b:passive destroy'); - }; - }, []); - return null; - } - - act(() => { - ReactNoop.renderToRootWithID( - <> - - - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'a:layout create', - 'Sync effect', - ]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded([ - 'a:passive create', - 'b:passive create', - ]); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['a:layout destroy']); - - // Simulate an XHR completing in the component without a pending passive effect.. - expect(completePendingRequest).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component.", - ); - }); - }); - - it('still warns about state updates from within passive unmount function', () => { - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive create'); - return () => { - setDidLoad(true); - Scheduler.unstable_yieldValue('passive destroy'); - }; - }, []); - return didLoad; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'Sync effect', - 'passive create', - ]); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(() => { - expect(Scheduler).toFlushAndYield(['passive destroy']); - }).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component.", - ); - }); - }); + return children[0].prop; } - it('updates have async priority', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue( + `Committed state when effect was fired: ${getCommittedText()}`, ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [0]']); - expect(Scheduler).toFlushAndYield(['Count: 0']); - }); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [1]']); - expect(Scheduler).toFlushAndYield(['Count: 1']); }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([0, 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + // Before the effects have a chance to flush, schedule another update + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + // The previous effect flushes before the reconciliation + 'Committed state when effect was fired: 0', + 1, + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); }); - it('updates have async priority even if effects are flushed early', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + expect(Scheduler).toHaveYielded([ + 'Committed state when effect was fired: 1', + ]); + }, + ); - // Rendering again should flush the previous commit's effects - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Schedule update [0]', - 'Count: 0', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + it('defers passive effect destroy functions during unmount', () => { + function Child({bar, foo}) { + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive bar create'); + return () => { + Scheduler.unstable_yieldValue('passive bar destroy'); + }; + }, [bar]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout bar create'); + return () => { + Scheduler.unstable_yieldValue('layout bar destroy'); + }; + }, [bar]); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive foo create'); + return () => { + Scheduler.unstable_yieldValue('passive foo destroy'); + }; + }, [foo]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout foo create'); + return () => { + Scheduler.unstable_yieldValue('layout foo destroy'); + }; + }, [foo]); + Scheduler.unstable_yieldValue('render'); + return null; + } - expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [1]']); - expect(Scheduler).toFlushAndYield(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout bar create', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar create', + 'passive foo create', + ]); }); - it('flushes passive effects when flushing discrete updates', () => { - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - useEffect(() => { - Scheduler.unstable_yieldValue(`Will set count to 1`); - updateCount(1); - }, []); - return ; - } + // This update is exists to test an internal implementation detail: + // Effects without updating dependencies lose their layout/passive tag during an update. + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout foo destroy', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive foo destroy', + 'passive foo create', + ]); + }); - // we explicitly wait for missing act() warnings here since - // it's a lot harder to simulate this condition inside an act scope - expect(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }).toErrorDev(['An update to Counter ran an effect']); + // Unmount the component and verify that passive destroy functions are deferred until post-commit. + act(() => { + ReactNoop.render(null, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'layout bar destroy', + 'layout foo destroy', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar destroy', + 'passive foo destroy', + ]); + }); + }); - // A discrete event forces the passive effect to be flushed -- - // updateCount(1) happens first, so 2 wins. - ReactNoop.flushDiscreteUpdates(); - ReactNoop.discreteUpdates(() => { - // (use batchedUpdates to silence the act() warning) - ReactNoop.batchedUpdates(() => { - _updateCount(2); - }); - }); - expect(Scheduler).toHaveYielded(['Will set count to 1']); - expect(() => { - expect(Scheduler).toFlushAndYield(['Count: 2']); - }).toErrorDev([ - 'An update to Counter ran an effect', - 'An update to Counter ran an effect', + it('does not warn about state updates for unmounted components with pending passive unmounts', () => { + let completePendingRequest = null; + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout create'); + return () => { + Scheduler.unstable_yieldValue('layout destroy'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + return () => { + Scheduler.unstable_yieldValue('passive destroy'); + }; + }, []); + return didLoad; + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'layout create', + 'Sync effect', + ]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['passive create']); + + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); + + // Simulate an XHR completing, which will cause a state update- + // but should not log a warning. + completePendingRequest(); + + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['passive destroy']); + }); + }); + + it('still warns about state updates for unmounted components with no pending passive unmounts', () => { + let completePendingRequest = null; + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + return () => { + Scheduler.unstable_yieldValue('layout destroy'); + }; + }, []); + return didLoad; + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'layout create', + 'Sync effect', ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); + + // Simulate an XHR completing. + expect(completePendingRequest).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component.", + ); }); + }); - it('flushes passive effects when flushing discrete updates (with tracing)', () => { - const onInteractionScheduledWorkCompleted = jest.fn(); - const onWorkCanceled = jest.fn(); - SchedulerTracing.unstable_subscribe({ - onInteractionScheduledWorkCompleted, - onInteractionTraced: jest.fn(), - onWorkCanceled, - onWorkScheduled: jest.fn(), - onWorkStarted: jest.fn(), - onWorkStopped: jest.fn(), - }); + it('still warns if there are pending passive unmount effects but not for the current fiber', () => { + let completePendingRequest = null; + function ComponentWithXHR() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('a:layout create'); + return () => { + Scheduler.unstable_yieldValue('a:layout destroy'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('a:passive create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + }, []); + return didLoad; + } - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - useEffect(() => { - expect( - SchedulerTracing.unstable_getCurrent(), - ).toMatchInteractions([tracingEvent]); - Scheduler.unstable_yieldValue(`Will set count to 1`); - updateCount(1); - }, []); - return ; - } + function ComponentWithPendingPassiveUnmount() { + React.useEffect(() => { + Scheduler.unstable_yieldValue('b:passive create'); + return () => { + Scheduler.unstable_yieldValue('b:passive destroy'); + }; + }, []); + return null; + } - const tracingEvent = {id: 0, name: 'hello', timestamp: 0}; - // we explicitly wait for missing act() warnings here since - // it's a lot harder to simulate this condition inside an act scope - expect(() => { - SchedulerTracing.unstable_trace( - tracingEvent.name, - tracingEvent.timestamp, - () => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - }, - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }).toErrorDev(['An update to Counter ran an effect']); - - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); - - // A discrete event forces the passive effect to be flushed -- - // updateCount(1) happens first, so 2 wins. - ReactNoop.flushDiscreteUpdates(); - ReactNoop.discreteUpdates(() => { - // (use batchedUpdates to silence the act() warning) - ReactNoop.batchedUpdates(() => { - _updateCount(2); - }); - }); - expect(Scheduler).toHaveYielded(['Will set count to 1']); - expect(() => { - expect(Scheduler).toFlushAndYield(['Count: 2']); - }).toErrorDev([ - 'An update to Counter ran an effect', - 'An update to Counter ran an effect', + act(() => { + ReactNoop.renderToRootWithID( + <> + + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'a:layout create', + 'Sync effect', + ]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded([ + 'a:passive create', + 'b:passive create', ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['a:layout destroy']); - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); - expect(onWorkCanceled).toHaveBeenCalledTimes(0); + // Simulate an XHR completing in the component without a pending passive effect.. + expect(completePendingRequest).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component.", + ); }); + }); - it( - 'in legacy mode, useEffect is deferred and updates finish synchronously ' + - '(in a single batch)', + it('still warns about state updates from within passive unmount function', () => { + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive create'); + return () => { + setDidLoad(true); + Scheduler.unstable_yieldValue('passive destroy'); + }; + }, []); + return didLoad; + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'Sync effect', + 'passive create', + ]); + + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(() => { + expect(Scheduler).toFlushAndYield(['passive destroy']); + }).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component.", + ); + }); + }); + } + + it('updates have async priority', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [0]']); + expect(Scheduler).toFlushAndYield(['Count: 0']); + }); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [1]']); + expect(Scheduler).toFlushAndYield(['Count: 1']); + }); + }); + + it('updates have async priority even if effects are flushed early', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + // Rendering again should flush the previous commit's effects + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Schedule update [0]', + 'Count: 0', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [1]']); + expect(Scheduler).toFlushAndYield(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); + + it('flushes passive effects when flushing discrete updates', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + Scheduler.unstable_yieldValue(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } + + // we explicitly wait for missing act() warnings here since + // it's a lot harder to simulate this condition inside an act scope + expect(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }).toErrorDev(['An update to Counter ran an effect']); + + // A discrete event forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + ReactNoop.flushDiscreteUpdates(); + ReactNoop.discreteUpdates(() => { + // (use batchedUpdates to silence the act() warning) + ReactNoop.batchedUpdates(() => { + _updateCount(2); + }); + }); + expect(Scheduler).toHaveYielded(['Will set count to 1']); + expect(() => { + expect(Scheduler).toFlushAndYield(['Count: 2']); + }).toErrorDev([ + 'An update to Counter ran an effect', + 'An update to Counter ran an effect', + ]); + + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + }); + + it('flushes passive effects when flushing discrete updates (with tracing)', () => { + const onInteractionScheduledWorkCompleted = jest.fn(); + const onWorkCanceled = jest.fn(); + SchedulerTracing.unstable_subscribe({ + onInteractionScheduledWorkCompleted, + onInteractionTraced: jest.fn(), + onWorkCanceled, + onWorkScheduled: jest.fn(), + onWorkStarted: jest.fn(), + onWorkStopped: jest.fn(), + }); + + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + expect(SchedulerTracing.unstable_getCurrent()).toMatchInteractions([ + tracingEvent, + ]); + Scheduler.unstable_yieldValue(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } + + const tracingEvent = {id: 0, name: 'hello', timestamp: 0}; + // we explicitly wait for missing act() warnings here since + // it's a lot harder to simulate this condition inside an act scope + expect(() => { + SchedulerTracing.unstable_trace( + tracingEvent.name, + tracingEvent.timestamp, () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - // Update multiple times. These should all be batched together in - // a single render. - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.renderLegacySyncRoot(); - // Even in legacy mode, effects are deferred until after paint - expect(Scheduler).toFlushAndYieldThrough(['Count: (empty)']); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - }); - - // effects get fored on exiting act() - // There were multiple updates, but there should only be a - // single render - expect(Scheduler).toHaveYielded(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); }, ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }).toErrorDev(['An update to Counter ran an effect']); - it('flushSync is not allowed', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - ReactNoop.flushSync(() => { - updateCount(props.count); - }); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - expect(() => { - ReactNoop.flushPassiveEffects(); - }).toThrow('flushSync was called from inside a lifecycle method'); - }); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); + + // A discrete event forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + ReactNoop.flushDiscreteUpdates(); + ReactNoop.discreteUpdates(() => { + // (use batchedUpdates to silence the act() warning) + ReactNoop.batchedUpdates(() => { + _updateCount(2); + }); + }); + expect(Scheduler).toHaveYielded(['Will set count to 1']); + expect(() => { + expect(Scheduler).toFlushAndYield(['Count: 2']); + }).toErrorDev([ + 'An update to Counter ran an effect', + 'An update to Counter ran an effect', + ]); + + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).toHaveBeenCalledTimes(0); + }); + + it( + 'in legacy mode, useEffect is deferred and updates finish synchronously ' + + '(in a single batch)', + () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + // Update multiple times. These should all be batched together in + // a single render. + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.renderLegacySyncRoot(); + // Even in legacy mode, effects are deferred until after paint + expect(Scheduler).toFlushAndYieldThrough(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); }); - it('unmounts previous effect', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + // effects get fored on exiting act() + // There were multiple updates, but there should only be a + // single render + expect(Scheduler).toHaveYielded(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }, + ); + + it('flushSync is not allowed', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + ReactNoop.flushSync(() => { + updateCount(props.count); }); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + expect(() => { + ReactNoop.flushPassiveEffects(); + }).toThrow('flushSync was called from inside a lifecycle method'); + }); + }); - expect(Scheduler).toHaveYielded(['Did create [0]']); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - - expect(Scheduler).toHaveYielded([ - 'Did destroy [0]', - 'Did create [1]', - ]); + it('unmounts previous effect', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - it('unmounts on deletion', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); + expect(Scheduler).toHaveYielded(['Did create [0]']); - expect(Scheduler).toHaveYielded(['Did create [0]']); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy [0]']); - expect(ReactNoop.getChildren()).toEqual([]); + expect(Scheduler).toHaveYielded(['Did destroy [0]', 'Did create [1]']); + }); + + it('unmounts on deletion', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - it('unmounts on deletion after skipped effect', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; - }, []); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); + expect(Scheduler).toHaveYielded(['Did create [0]']); - expect(Scheduler).toHaveYielded(['Did create [0]']); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + it('unmounts on deletion after skipped effect', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; + }, []); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - expect(Scheduler).toHaveYielded([]); + expect(Scheduler).toHaveYielded(['Did create [0]']); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy [0]']); - expect(ReactNoop.getChildren()).toEqual([]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + expect(Scheduler).toHaveYielded([]); + + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('always fires effects if no dependencies are provided', () => { + function effect() { + Scheduler.unstable_yieldValue(`Did create`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy`); + }; + } + function Counter(props) { + useEffect(effect); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); + + expect(Scheduler).toHaveYielded(['Did create']); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); + + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('skips effect if inputs have not changed', () => { + function Counter(props) { + const text = `${props.label}: ${props.count}`; + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${text}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${text}]`); + }; + }, [props.label, props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + }); + + expect(Scheduler).toHaveYielded(['Did create [Count: 0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Count changed + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + expect(Scheduler).toHaveYielded([ + 'Did destroy [Count: 0]', + 'Did create [Count: 1]', + ]); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Nothing changed, so no effect should have fired + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + }); + + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Label changed + expect(Scheduler).toFlushAndYieldThrough(['Total: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); + }); + + expect(Scheduler).toHaveYielded([ + 'Did destroy [Count: 1]', + 'Did create [Total: 1]', + ]); + }); + + it('multiple effects', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did commit 1 [${props.count}]`); }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Did commit 2 [${props.count}]`); + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - it('always fires effects if no dependencies are provided', () => { - function effect() { - Scheduler.unstable_yieldValue(`Did create`); + expect(Scheduler).toHaveYielded(['Did commit 1 [0]', 'Did commit 2 [0]']); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + expect(Scheduler).toHaveYielded(['Did commit 1 [1]', 'Did commit 2 [1]']); + }); + + it('unmounts all previous effects before creating any new ones', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); + + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); + }); + + if (runAllPassiveEffectDestroysBeforeCreates) { + it('unmounts all previous effects between siblings before creating any new ones', () => { + function Counter({count, label}) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount ${label} [${count}]`); return () => { - Scheduler.unstable_yieldValue(`Did destroy`); + Scheduler.unstable_yieldValue(`Unmount ${label} [${count}]`); }; - } - function Counter(props) { - useEffect(effect); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); }); - - expect(Scheduler).toHaveYielded(['Did create']); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - - expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); - - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy']); - expect(ReactNoop.getChildren()).toEqual([]); + return ; + } + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'A 0', + 'B 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('A 0'), span('B 0')]); }); - it('skips effect if inputs have not changed', () => { - function Counter(props) { - const text = `${props.label}: ${props.count}`; - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${text}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${text}]`); - }; - }, [props.label, props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - }); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - expect(Scheduler).toHaveYielded(['Did create [Count: 0]']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Count changed - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - - expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 0]', - 'Did create [Count: 1]', + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'A 1', + 'B 1', + 'Sync effect', ]); + expect(ReactNoop.getChildren()).toEqual([span('A 1'), span('B 1')]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Nothing changed, so no effect should have fired - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - }); + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'B 2', + 'C 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('B 2'), span('C 0')]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [1]', + 'Unmount B [1]', + 'Mount B [2]', + 'Mount C [0]', + ]); + }); + } - expect(Scheduler).toHaveYielded([]); + it('handles errors in create on mount', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue('Oops!'); + throw new Error('Oops!'); + // eslint-disable-next-line no-unreachable + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); + }); + + expect(Scheduler).toHaveYielded([ + 'Mount A [0]', + 'Oops!', + // Clean up effect A. There's no effect B to clean-up, because it + // never mounted. + 'Unmount A [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('handles errors in create on update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + if (props.count === 1) { + Scheduler.unstable_yieldValue('Oops!'); + throw new Error('Oops!'); + } + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + }); + + act(() => { + // This update will trigger an error + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); + expect(Scheduler).toHaveYielded( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ? ['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!'] + : [ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Oops!', + 'Unmount A [1]', + ], + ); + expect(ReactNoop.getChildren()).toEqual([]); + }); + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + expect(Scheduler).toHaveYielded([ + // Clean up effect A runs passively on unmount. + // There's no effect B to clean-up, because it never mounted. + 'Unmount A [1]', + ]); + } + }); + + it('handles errors in destroy on update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue('Oops!'); + if (props.count === 0) { + throw new Error('Oops!'); + } + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + }); + + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + act(() => { + // This update will trigger an error during passive effect unmount + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Label changed - expect(Scheduler).toFlushAndYieldThrough([ - 'Total: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); - }); - + // This branch enables a feature flag that flushes all passive destroys in a + // separate pass before flushing any passive creates. + // A result of this two-pass flush is that an error thrown from unmount does + // not block the subsequent create functions from being run. expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 1]', - 'Did create [Total: 1]', - ]); - }); - - it('multiple effects', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 1 [${props.count}]`); - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 2 [${props.count}]`); - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - - expect(Scheduler).toHaveYielded([ - 'Did commit 1 [0]', - 'Did commit 2 [0]', - ]); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - expect(Scheduler).toHaveYielded([ - 'Did commit 1 [1]', - 'Did commit 2 [1]', - ]); - }); - - it('unmounts all previous effects before creating any new ones', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [0]', + 'Oops!', 'Unmount B [0]', 'Mount A [1]', 'Mount B [1]', ]); }); - if (runAllPassiveEffectDestroysBeforeCreates) { - it('unmounts all previous effects between siblings before creating any new ones', () => { - function Counter({count, label}) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount ${label} [${count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount ${label} [${count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'A 0', - 'B 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('A 0'), - span('B 0'), - ]); - }); - - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'A 1', - 'B 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('A 1'), - span('B 1'), - ]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Mount B [1]', - ]); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'B 2', - 'C 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('B 2'), - span('C 0'), - ]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [1]', - 'Unmount B [1]', - 'Mount B [2]', - 'Mount C [0]', - ]); - }); - } - - it('handles errors in create on mount', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - // eslint-disable-next-line no-unreachable - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - }); - - expect(Scheduler).toHaveYielded([ - 'Mount A [0]', - 'Oops!', - // Clean up effect A. There's no effect B to clean-up, because it - // never mounted. - 'Unmount A [0]', - ]); - expect(ReactNoop.getChildren()).toEqual([]); - }); - - it('handles errors in create on update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; - }); - useEffect(() => { - if (props.count === 1) { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - } - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; - }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - }); - - act(() => { - // This update will trigger an error - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - expect(Scheduler).toHaveYielded( - deferPassiveEffectCleanupDuringUnmount && - runAllPassiveEffectDestroysBeforeCreates - ? ['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!'] - : [ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Oops!', - 'Unmount A [1]', - ], - ); - expect(ReactNoop.getChildren()).toEqual([]); - }); - if ( - deferPassiveEffectCleanupDuringUnmount && - runAllPassiveEffectDestroysBeforeCreates - ) { - expect(Scheduler).toHaveYielded([ - // Clean up effect A runs passively on unmount. - // There's no effect B to clean-up, because it never mounted. - 'Unmount A [1]', - ]); - } - }); - - it('handles errors in destroy on update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue('Oops!'); - if (props.count === 0) { - throw new Error('Oops!'); - } - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; - }); - return ; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - }); - - if ( - deferPassiveEffectCleanupDuringUnmount && - runAllPassiveEffectDestroysBeforeCreates - ) { - act(() => { - // This update will trigger an error during passive effect unmount - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - - // This branch enables a feature flag that flushes all passive destroys in a - // separate pass before flushing any passive creates. - // A result of this two-pass flush is that an error thrown from unmount does - // not block the subsequent create functions from being run. - expect(Scheduler).toHaveYielded([ - 'Oops!', - 'Unmount B [0]', - 'Mount A [1]', - 'Mount B [1]', - ]); - }); - - // gets unmounted because an error is thrown above. - // The remaining destroy functions are run later on unmount, since they're passive. - // In this case, one of them throws again (because of how the test is written). - expect(Scheduler).toHaveYielded(['Oops!', 'Unmount B [1]']); - expect(ReactNoop.getChildren()).toEqual([]); - } else { - act(() => { - // This update will trigger an error during passive effect unmount - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(() => { - expect(Scheduler).toFlushAndYield(['Count: 1', 'Sync effect']); - }).toThrow('Oops!'); - expect(ReactNoop.getChildren()).toEqual([]); - ReactNoop.flushPassiveEffects(); - }); - } - }); - - it('works with memo', () => { - function Counter({count}) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue('Mount: ' + count); - return () => Scheduler.unstable_yieldValue('Unmount: ' + count); - }); - return ; - } - Counter = memo(Counter); - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Mount: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - + // gets unmounted because an error is thrown above. + // The remaining destroy functions are run later on unmount, since they're passive. + // In this case, one of them throws again (because of how the test is written). + expect(Scheduler).toHaveYielded(['Oops!', 'Unmount B [1]']); + expect(ReactNoop.getChildren()).toEqual([]); + } else { + act(() => { + // This update will trigger an error during passive effect unmount ReactNoop.render(, () => Scheduler.unstable_yieldValue('Sync effect'), ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Unmount: 0', - 'Mount: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - - ReactNoop.render(null); - expect(Scheduler).toFlushAndYieldThrough(['Unmount: 1']); - expect(ReactNoop.getChildren()).toEqual([]); - }); - }); - - describe('useLayoutEffect', () => { - it('fires layout effects after the host has been mutated', () => { - function getCommittedText() { - const yields = Scheduler.unstable_clearYields(); - const children = ReactNoop.getChildren(); - Scheduler.unstable_yieldValue(yields); - if (children === null) { - return null; - } - return children[0].prop; - } - - function Counter(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue(`Current: ${getCommittedText()}`); - }); - return ; - } - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - [0], - 'Current: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(0)]); - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - [1], - 'Current: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(1)]); - }); - - it('force flushes passive effects before firing new layout effects', () => { - let committedText = '(empty)'; - - function Counter(props) { - useLayoutEffect(() => { - // Normally this would go in a mutation effect, but this test - // intentionally omits a mutation effect. - committedText = props.count + ''; - - Scheduler.unstable_yieldValue( - `Mount layout [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount layout [current: ${committedText}]`, - ); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue( - `Mount normal [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount normal [current: ${committedText}]`, - ); - }; - }); - return null; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Mount layout [current: 0]', - 'Sync effect', - ]); - expect(committedText).toEqual('0'); - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Mount normal [current: 0]', - 'Unmount layout [current: 0]', - 'Mount layout [current: 1]', - 'Sync effect', - ]); - expect(committedText).toEqual('1'); - }); - - expect(Scheduler).toHaveYielded([ - 'Unmount normal [current: 1]', - 'Mount normal [current: 1]', - ]); - }); - }); - - describe('useCallback', () => { - it('memoizes callback by comparing inputs', () => { - class IncrementButton extends React.PureComponent { - increment = () => { - this.props.increment(); - }; - render() { - return ; - } - } - - function Counter({incrementBy}) { - const [count, updateCount] = useState(0); - const increment = useCallback( - () => updateCount(c => c + incrementBy), - [incrementBy], - ); - return ( - <> - - - - ); - } - - const button = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 0'), - ]); - - act(button.current.increment); - expect(Scheduler).toHaveYielded([ - // Button should not re-render, because its props haven't changed - // 'Increment', - 'Count: 1', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 1'), - ]); - - // Increase the increment amount - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // Inputs did change this time - 'Increment', - 'Count: 1', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 1'), - ]); - - // Callback should have updated - act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 11'), - ]); - }); - }); - - describe('useMemo', () => { - it('memoizes value by comparing to previous inputs', () => { - function CapitalizedText(props) { - const text = props.text; - const capitalizedText = useMemo(() => { - Scheduler.unstable_yieldValue(`Capitalize '${text}'`); - return text.toUpperCase(); - }, [text]); - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'hello'", 'HELLO']); - expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'hi'", 'HI']); - expect(ReactNoop.getChildren()).toEqual([span('HI')]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['HI']); - expect(ReactNoop.getChildren()).toEqual([span('HI')]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - "Capitalize 'goodbye'", - 'GOODBYE', - ]); - expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); - }); - - it('always re-computes if no inputs are provided', () => { - function LazyCompute(props) { - const computed = useMemo(props.compute); - return ; - } - - function computeA() { - Scheduler.unstable_yieldValue('compute A'); - return 'A'; - } - - function computeB() { - Scheduler.unstable_yieldValue('compute B'); - return 'B'; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute B', 'B']); - }); - - it('should not invoke memoized function during re-renders unless inputs change', () => { - function LazyCompute(props) { - const computed = useMemo(() => props.compute(props.input), [ - props.input, - ]); - const [count, setCount] = useState(0); - if (count < 3) { - setCount(count + 1); - } - return ; - } - - function compute(val) { - Scheduler.unstable_yieldValue('compute ' + val); - return val; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute B', 'B']); - }); - }); - - describe('useRef', () => { - it('creates a ref object initialized with the provided value', () => { - jest.useFakeTimers(); - - function useDebouncedCallback(callback, ms, inputs) { - const timeoutID = useRef(-1); - useEffect(() => { - return function unmount() { - clearTimeout(timeoutID.current); - }; - }, []); - const debouncedCallback = useCallback( - (...args) => { - clearTimeout(timeoutID.current); - timeoutID.current = setTimeout(callback, ms, ...args); - }, - [callback, ms], - ); - return useCallback(debouncedCallback, inputs); - } - - let ping; - function App() { - ping = useDebouncedCallback( - value => { - Scheduler.unstable_yieldValue('ping: ' + value); - }, - 100, - [], - ); - return null; - } - - act(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded([]); - - ping(1); - ping(2); - ping(3); - - expect(Scheduler).toHaveYielded([]); - - jest.advanceTimersByTime(100); - - expect(Scheduler).toHaveYielded(['ping: 3']); - - ping(4); - jest.advanceTimersByTime(20); - ping(5); - ping(6); - jest.advanceTimersByTime(80); - - expect(Scheduler).toHaveYielded([]); - - jest.advanceTimersByTime(20); - expect(Scheduler).toHaveYielded(['ping: 6']); - }); - - it('should return the same ref during re-renders', () => { - function Counter() { - const ref = useRef('val'); - const [count, setCount] = useState(0); - const [firstRef] = useState(ref); - - if (firstRef !== ref) { - throw new Error('should never change'); - } - - if (count < 3) { - setCount(count + 1); - } - - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['val']); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['val']); - }); - }); - - describe('useImperativeHandle', () => { - it('does not update when deps are the same', () => { - const INCREMENT = 'INCREMENT'; - - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } - - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch}), []); - return ; - } - - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); - - act(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - // Intentionally not updated because of [] deps: - expect(counter.current.count).toBe(0); - }); - - // Regression test for https://github.com/facebook/react/issues/14782 - it('automatically updates when deps are not specified', () => { - const INCREMENT = 'INCREMENT'; - - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } - - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch})); - return ; - } - - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); - - act(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - }); - - it('updates when deps are different', () => { - const INCREMENT = 'INCREMENT'; - - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } - - let totalRefUpdates = 0; - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle( - ref, - () => { - totalRefUpdates++; - return {count, dispatch}; - }, - [count], - ); - return ; - } - - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); - expect(totalRefUpdates).toBe(1); - - act(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - expect(totalRefUpdates).toBe(2); - - // Update that doesn't change the ref dependencies - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - expect(totalRefUpdates).toBe(2); // Should not increase since last time - }); - }); - describe('useTransition', () => { - it.experimental( - 'delays showing loading state until after timeout', - async () => { - let transition; - function App() { - const [show, setShow] = useState(false); - const [startTransition, isPending] = useTransition({ - timeoutMs: 1000, - }); - transition = () => { - startTransition(() => { - setShow(true); - }); - }; - return ( - }> - {show ? ( - - ) : ( - - )} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Before... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: false'), - ]); - - act(() => { - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - transition, - ); - }); - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(Scheduler).toHaveYielded([ - 'Before... Pending: true', - 'Suspend! [After... Pending: false]', - 'Loading... Pending: false', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); - - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(ReactNoop.getChildren()).toEqual([ - hiddenSpan('Before... Pending: true'), - span('Loading... Pending: false'), - ]); - - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(Scheduler).toHaveYielded([ - 'Promise resolved [After... Pending: false]', - ]); - expect(Scheduler).toFlushAndYield(['After... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('After... Pending: false'), - ]); - }, - ); - it.experimental( - 'delays showing loading state until after busyDelayMs + busyMinDurationMs', - async () => { - let transition; - function App() { - const [show, setShow] = useState(false); - const [startTransition, isPending] = useTransition({ - busyDelayMs: 1000, - busyMinDurationMs: 2000, - }); - transition = () => { - startTransition(() => { - setShow(true); - }); - }; - return ( - }> - {show ? ( - - ) : ( - - )} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Before... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: false'), - ]); - - act(() => { - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - transition, - ); - }); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded([ - 'Before... Pending: true', - 'Suspend! [After... Pending: false]', - 'Loading... Pending: false', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); - - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded([ - 'Promise resolved [After... Pending: false]', - ]); - expect(Scheduler).toFlushAndYield(['After... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); - - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(ReactNoop.getChildren()).toEqual([ - span('After... Pending: false'), - ]); - }, - ); - }); - describe('useDeferredValue', () => { - it.experimental( - 'defers text value until specified timeout', - async () => { - function TextBox({text}) { - return ; - } - - let _setText; - function App() { - const [text, setText] = useState('A'); - const deferredText = useDeferredValue(text, { - timeoutMs: 500, - }); - _setText = setText; - return ( - <> - - }> - - - - ); - } - - act(() => { - ReactNoop.render(); - }); - - expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); - expect(ReactNoop.getChildren()).toEqual([ - span('A'), - span('Loading'), - ]); - - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushAndYield(['A']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]); - - act(() => { - _setText('B'); - }); - expect(Scheduler).toHaveYielded([ - 'B', - 'A', - 'B', - 'Suspend! [B]', - 'Loading', - ]); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); - - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); - - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(ReactNoop.getChildren()).toEqual([ - span('B'), - hiddenSpan('A'), - span('Loading'), - ]); - - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - - act(() => { - expect(Scheduler).toFlushAndYield(['B']); - }); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]); - }, - ); - }); - - describe('progressive enhancement (not supported)', () => { - it('mount additional state', () => { - let updateA; - let updateB; - // let updateC; - - function App(props) { - const [A, _updateA] = useState(0); - const [B, _updateB] = useState(0); - updateA = _updateA; - updateB = _updateB; - - let C; - if (props.loadC) { - useState(0); - } else { - C = '[not loaded]'; - } - - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: [not loaded]']); - expect(ReactNoop.getChildren()).toEqual([ - span('A: 0, B: 0, C: [not loaded]'), - ]); - - act(() => { - updateA(2); - updateB(3); - }); - - expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: [not loaded]']); - expect(ReactNoop.getChildren()).toEqual([ - span('A: 2, B: 3, C: [not loaded]'), - ]); - - ReactNoop.render(); expect(() => { - expect(() => { - expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 0']); - }).toThrow('Rendered more hooks than during the previous render'); - }).toErrorDev([ - 'Warning: React has detected a change in the order of Hooks called by App. ' + - 'This will lead to bugs and errors if not fixed. For more information, ' + - 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + - ' Previous render Next render\n' + - ' ------------------------------------------------------\n' + - '1. useState useState\n' + - '2. useState useState\n' + - '3. undefined useState\n' + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', - ]); - - // Uncomment if/when we support this again - // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); - - // updateC(4); - // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']); - // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + expect(Scheduler).toFlushAndYield(['Count: 1', 'Sync effect']); + }).toThrow('Oops!'); + expect(ReactNoop.getChildren()).toEqual([]); + ReactNoop.flushPassiveEffects(); }); + } + }); - it('unmount state', () => { - let updateA; - let updateB; - let updateC; - - function App(props) { - const [A, _updateA] = useState(0); - const [B, _updateB] = useState(0); - updateA = _updateA; - updateB = _updateB; - - let C; - if (props.loadC) { - const [_C, _updateC] = useState(0); - C = _C; - updateC = _updateC; - } else { - C = '[not loaded]'; - } - - return ; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: 0']); - expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); - act(() => { - updateA(2); - updateB(3); - updateC(4); - }); - expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: 4']); - expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndThrow( - 'Rendered fewer hooks than expected. This may be caused by an ' + - 'accidental early return statement.', - ); + it('works with memo', () => { + function Counter({count}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount: ' + count); + return () => Scheduler.unstable_yieldValue('Unmount: ' + count); }); + return ; + } + Counter = memo(Counter); - it('unmount effects', () => { - function App(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Mount A'); - return () => { - Scheduler.unstable_yieldValue('Unmount A'); - }; - }, []); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Mount: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - if (props.showMore) { - useEffect(() => { - Scheduler.unstable_yieldValue('Mount B'); - return () => { - Scheduler.unstable_yieldValue('Unmount B'); - }; - }, []); - } + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Unmount: 0', + 'Mount: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - return null; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); - }); - - expect(Scheduler).toHaveYielded(['Mount A']); - - act(() => { - ReactNoop.render(); - expect(() => { - expect(() => { - expect(Scheduler).toFlushAndYield([]); - }).toThrow('Rendered more hooks than during the previous render'); - }).toErrorDev([ - 'Warning: React has detected a change in the order of Hooks called by App. ' + - 'This will lead to bugs and errors if not fixed. For more information, ' + - 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + - ' Previous render Next render\n' + - ' ------------------------------------------------------\n' + - '1. useEffect useEffect\n' + - '2. undefined useEffect\n' + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', - ]); - }); - - // Uncomment if/when we support this again - // ReactNoop.flushPassiveEffects(); - // expect(Scheduler).toHaveYielded(['Mount B']); - - // ReactNoop.render(); - // expect(Scheduler).toFlushAndThrow( - // 'Rendered fewer hooks than expected. This may be caused by an ' + - // 'accidental early return statement.', - // ); - }); - }); - - it('eager bailout optimization should always compare to latest rendered reducer', () => { - // Edge case based on a bug report - let setCounter; - function App() { - const [counter, _setCounter] = useState(1); - setCounter = _setCounter; - return ; - } - - function Component({count}) { - const [state, dispatch] = useReducer(() => { - // This reducer closes over a value from props. If the reducer is not - // properly updated, the eager reducer will compare to an old value - // and bail out incorrectly. - Scheduler.unstable_yieldValue('Reducer: ' + count); - return count; - }, -1); - useEffect(() => { - Scheduler.unstable_yieldValue('Effect: ' + count); - dispatch(); - }, [count]); - Scheduler.unstable_yieldValue('Render: ' + state); - return count; - } - - act(() => { - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: -1', - 'Effect: 1', - 'Reducer: 1', - 'Reducer: 1', - 'Render: 1', - ]); - expect(ReactNoop).toMatchRenderedOutput('1'); - }); - - act(() => { - setCounter(2); - }); - expect(Scheduler).toHaveYielded([ - 'Render: 1', - 'Effect: 2', - 'Reducer: 2', - 'Reducer: 2', - 'Render: 2', - ]); - expect(ReactNoop).toMatchRenderedOutput('2'); - }); - - // Regression test. Covers a case where an internal state variable - // (`didReceiveUpdate`) is not reset properly. - it('state bail out edge case (#16359)', async () => { - let setCounterA; - let setCounterB; - - function CounterA() { - const [counter, setCounter] = useState(0); - setCounterA = setCounter; - Scheduler.unstable_yieldValue('Render A: ' + counter); - useEffect(() => { - Scheduler.unstable_yieldValue('Commit A: ' + counter); - }); - return counter; - } - - function CounterB() { - const [counter, setCounter] = useState(0); - setCounterB = setCounter; - Scheduler.unstable_yieldValue('Render B: ' + counter); - useEffect(() => { - Scheduler.unstable_yieldValue('Commit B: ' + counter); - }); - return counter; - } - - const root = ReactNoop.createRoot(null); - await ReactNoop.act(async () => { - root.render( - <> - - - , - ); - }); - expect(Scheduler).toHaveYielded([ - 'Render A: 0', - 'Render B: 0', - 'Commit A: 0', - 'Commit B: 0', - ]); - - await ReactNoop.act(async () => { - setCounterA(1); - - // In the same batch, update B twice. To trigger the condition we're - // testing, the first update is necessary to bypass the early - // bailout optimization. - setCounterB(1); - setCounterB(0); - }); - expect(Scheduler).toHaveYielded([ - 'Render A: 1', - 'Render B: 0', - 'Commit A: 1', - // B should not fire an effect because the update bailed out - // 'Commit B: 0', - ]); - }); - - it('should update latest rendered reducer when a preceding state receives a render phase update', () => { - // Similar to previous test, except using a preceding render phase update - // instead of new props. - let dispatch; - function App() { - const [step, setStep] = useState(0); - const [shadow, _dispatch] = useReducer(() => step, step); - dispatch = _dispatch; - - if (step < 5) { - setStep(step + 1); - } - - Scheduler.unstable_yieldValue(`Step: ${step}, Shadow: ${shadow}`); - return shadow; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Step: 0, Shadow: 0', - 'Step: 1, Shadow: 0', - 'Step: 2, Shadow: 0', - 'Step: 3, Shadow: 0', - 'Step: 4, Shadow: 0', - 'Step: 5, Shadow: 0', - ]); - expect(ReactNoop).toMatchRenderedOutput('0'); - - act(() => dispatch()); - expect(Scheduler).toHaveYielded(['Step: 5, Shadow: 5']); - expect(ReactNoop).toMatchRenderedOutput('5'); - }); - - it('should process the rest pending updates after a render phase update', () => { - // Similar to previous test, except using a preceding render phase update - // instead of new props. - let updateA; - let updateC; - function App() { - const [a, setA] = useState(false); - const [b, setB] = useState(false); - if (a !== b) { - setB(a); - } - // Even though we called setB above, - // we should still apply the changes to C, - // during this render pass. - const [c, setC] = useState(false); - updateA = setA; - updateC = setC; - return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`; - } - - act(() => ReactNoop.render()); - expect(ReactNoop).toMatchRenderedOutput('abc'); - - act(() => { - updateA(true); - // This update should not get dropped. - updateC(true); - }); - expect(ReactNoop).toMatchRenderedOutput('ABC'); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYieldThrough(['Unmount: 1']); + expect(ReactNoop.getChildren()).toEqual([]); }); }); + + describe('useLayoutEffect', () => { + it('fires layout effects after the host has been mutated', () => { + function getCommittedText() { + const yields = Scheduler.unstable_clearYields(); + const children = ReactNoop.getChildren(); + Scheduler.unstable_yieldValue(yields); + if (children === null) { + return null; + } + return children[0].prop; + } + + function Counter(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`Current: ${getCommittedText()}`); + }); + return ; + } + + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + [0], + 'Current: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + [1], + 'Current: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); + + it('force flushes passive effects before firing new layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useLayoutEffect(() => { + // Normally this would go in a mutation effect, but this test + // intentionally omits a mutation effect. + committedText = props.count + ''; + + Scheduler.unstable_yieldValue( + `Mount layout [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Mount normal [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount normal [current: ${committedText}]`, + ); + }; + }); + return null; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Mount layout [current: 0]', + 'Sync effect', + ]); + expect(committedText).toEqual('0'); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Mount normal [current: 0]', + 'Unmount layout [current: 0]', + 'Mount layout [current: 1]', + 'Sync effect', + ]); + expect(committedText).toEqual('1'); + }); + + expect(Scheduler).toHaveYielded([ + 'Unmount normal [current: 1]', + 'Mount normal [current: 1]', + ]); + }); + }); + + describe('useCallback', () => { + it('memoizes callback by comparing inputs', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.increment(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useCallback(() => updateCount(c => c + incrementBy), [ + incrementBy, + ]); + return ( + <> + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Button should not re-render, because its props haven't changed + // 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Increase the increment amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // Inputs did change this time + 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Callback should have updated + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 11'), + ]); + }); + }); + + describe('useMemo', () => { + it('memoizes value by comparing to previous inputs', () => { + function CapitalizedText(props) { + const text = props.text; + const capitalizedText = useMemo(() => { + Scheduler.unstable_yieldValue(`Capitalize '${text}'`); + return text.toUpperCase(); + }, [text]); + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(["Capitalize 'hello'", 'HELLO']); + expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(["Capitalize 'hi'", 'HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(["Capitalize 'goodbye'", 'GOODBYE']); + expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); + }); + + it('always re-computes if no inputs are provided', () => { + function LazyCompute(props) { + const computed = useMemo(props.compute); + return ; + } + + function computeA() { + Scheduler.unstable_yieldValue('compute A'); + return 'A'; + } + + function computeB() { + Scheduler.unstable_yieldValue('compute B'); + return 'B'; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute B', 'B']); + }); + + it('should not invoke memoized function during re-renders unless inputs change', () => { + function LazyCompute(props) { + const computed = useMemo(() => props.compute(props.input), [ + props.input, + ]); + const [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + return ; + } + + function compute(val) { + Scheduler.unstable_yieldValue('compute ' + val); + return val; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute B', 'B']); + }); + }); + + describe('useRef', () => { + it('creates a ref object initialized with the provided value', () => { + jest.useFakeTimers(); + + function useDebouncedCallback(callback, ms, inputs) { + const timeoutID = useRef(-1); + useEffect(() => { + return function unmount() { + clearTimeout(timeoutID.current); + }; + }, []); + const debouncedCallback = useCallback( + (...args) => { + clearTimeout(timeoutID.current); + timeoutID.current = setTimeout(callback, ms, ...args); + }, + [callback, ms], + ); + return useCallback(debouncedCallback, inputs); + } + + let ping; + function App() { + ping = useDebouncedCallback( + value => { + Scheduler.unstable_yieldValue('ping: ' + value); + }, + 100, + [], + ); + return null; + } + + act(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([]); + + ping(1); + ping(2); + ping(3); + + expect(Scheduler).toHaveYielded([]); + + jest.advanceTimersByTime(100); + + expect(Scheduler).toHaveYielded(['ping: 3']); + + ping(4); + jest.advanceTimersByTime(20); + ping(5); + ping(6); + jest.advanceTimersByTime(80); + + expect(Scheduler).toHaveYielded([]); + + jest.advanceTimersByTime(20); + expect(Scheduler).toHaveYielded(['ping: 6']); + }); + + it('should return the same ref during re-renders', () => { + function Counter() { + const ref = useRef('val'); + const [count, setCount] = useState(0); + const [firstRef] = useState(ref); + + if (firstRef !== ref) { + throw new Error('should never change'); + } + + if (count < 3) { + setCount(count + 1); + } + + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['val']); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['val']); + }); + }); + + describe('useImperativeHandle', () => { + it('does not update when deps are the same', () => { + const INCREMENT = 'INCREMENT'; + + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch}), []); + return ; + } + + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); + + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Intentionally not updated because of [] deps: + expect(counter.current.count).toBe(0); + }); + + // Regression test for https://github.com/facebook/react/issues/14782 + it('automatically updates when deps are not specified', () => { + const INCREMENT = 'INCREMENT'; + + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch})); + return ; + } + + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); + + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + }); + + it('updates when deps are different', () => { + const INCREMENT = 'INCREMENT'; + + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } + + let totalRefUpdates = 0; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle( + ref, + () => { + totalRefUpdates++; + return {count, dispatch}; + }, + [count], + ); + return ; + } + + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); + expect(totalRefUpdates).toBe(1); + + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + expect(totalRefUpdates).toBe(2); + + // Update that doesn't change the ref dependencies + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + expect(totalRefUpdates).toBe(2); // Should not increase since last time + }); + }); + describe('useTransition', () => { + it.experimental( + 'delays showing loading state until after timeout', + async () => { + let transition; + function App() { + const [show, setShow] = useState(false); + const [startTransition, isPending] = useTransition({ + timeoutMs: 1000, + }); + transition = () => { + startTransition(() => { + setShow(true); + }); + }; + return ( + }> + {show ? ( + + ) : ( + + )} + + ); + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Before... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: false'), + ]); + + act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + transition, + ); + }); + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(Scheduler).toHaveYielded([ + 'Before... Pending: true', + 'Suspend! [After... Pending: false]', + 'Loading... Pending: false', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('Before... Pending: true'), + span('Loading... Pending: false'), + ]); + + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [After... Pending: false]', + ]); + expect(Scheduler).toFlushAndYield(['After... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('After... Pending: false'), + ]); + }, + ); + it.experimental( + 'delays showing loading state until after busyDelayMs + busyMinDurationMs', + async () => { + let transition; + function App() { + const [show, setShow] = useState(false); + const [startTransition, isPending] = useTransition({ + busyDelayMs: 1000, + busyMinDurationMs: 2000, + }); + transition = () => { + startTransition(() => { + setShow(true); + }); + }; + return ( + }> + {show ? ( + + ) : ( + + )} + + ); + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Before... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: false'), + ]); + + act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + transition, + ); + }); + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Before... Pending: true', + 'Suspend! [After... Pending: false]', + 'Loading... Pending: false', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [After... Pending: false]', + ]); + expect(Scheduler).toFlushAndYield(['After... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(ReactNoop.getChildren()).toEqual([ + span('After... Pending: false'), + ]); + }, + ); + }); + describe('useDeferredValue', () => { + it.experimental('defers text value until specified timeout', async () => { + function TextBox({text}) { + return ; + } + + let _setText; + function App() { + const [text, setText] = useState('A'); + const deferredText = useDeferredValue(text, { + timeoutMs: 500, + }); + _setText = setText; + return ( + <> + + }> + + + + ); + } + + act(() => { + ReactNoop.render(); + }); + + expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading')]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]); + + act(() => { + _setText('B'); + }); + expect(Scheduler).toHaveYielded([ + 'B', + 'A', + 'B', + 'Suspend! [B]', + 'Loading', + ]); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); + + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); + + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(ReactNoop.getChildren()).toEqual([ + span('B'), + hiddenSpan('A'), + span('Loading'), + ]); + + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + + act(() => { + expect(Scheduler).toFlushAndYield(['B']); + }); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]); + }); + }); + + describe('progressive enhancement (not supported)', () => { + it('mount additional state', () => { + let updateA; + let updateB; + // let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + useState(0); + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 0, B: 0, C: [not loaded]'), + ]); + + act(() => { + updateA(2); + updateB(3); + }); + + expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); + + ReactNoop.render(); + expect(() => { + expect(() => { + expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 0']); + }).toThrow('Rendered more hooks than during the previous render'); + }).toErrorDev([ + 'Warning: React has detected a change in the order of Hooks called by App. ' + + 'This will lead to bugs and errors if not fixed. For more information, ' + + 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + + ' Previous render Next render\n' + + ' ------------------------------------------------------\n' + + '1. useState useState\n' + + '2. useState useState\n' + + '3. undefined useState\n' + + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', + ]); + + // Uncomment if/when we support this again + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); + + // updateC(4); + // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']); + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + }); + + it('unmount state', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: 0']); + expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); + act(() => { + updateA(2); + updateB(3); + updateC(4); + }); + expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: 4']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'Rendered fewer hooks than expected. This may be caused by an ' + + 'accidental early return statement.', + ); + }); + + it('unmount effects', () => { + function App(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Mount A'); + return () => { + Scheduler.unstable_yieldValue('Unmount A'); + }; + }, []); + + if (props.showMore) { + useEffect(() => { + Scheduler.unstable_yieldValue('Mount B'); + return () => { + Scheduler.unstable_yieldValue('Unmount B'); + }; + }, []); + } + + return null; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); + }); + + expect(Scheduler).toHaveYielded(['Mount A']); + + act(() => { + ReactNoop.render(); + expect(() => { + expect(() => { + expect(Scheduler).toFlushAndYield([]); + }).toThrow('Rendered more hooks than during the previous render'); + }).toErrorDev([ + 'Warning: React has detected a change in the order of Hooks called by App. ' + + 'This will lead to bugs and errors if not fixed. For more information, ' + + 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + + ' Previous render Next render\n' + + ' ------------------------------------------------------\n' + + '1. useEffect useEffect\n' + + '2. undefined useEffect\n' + + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', + ]); + }); + + // Uncomment if/when we support this again + // ReactNoop.flushPassiveEffects(); + // expect(Scheduler).toHaveYielded(['Mount B']); + + // ReactNoop.render(); + // expect(Scheduler).toFlushAndThrow( + // 'Rendered fewer hooks than expected. This may be caused by an ' + + // 'accidental early return statement.', + // ); + }); + }); + + it('eager bailout optimization should always compare to latest rendered reducer', () => { + // Edge case based on a bug report + let setCounter; + function App() { + const [counter, _setCounter] = useState(1); + setCounter = _setCounter; + return ; + } + + function Component({count}) { + const [state, dispatch] = useReducer(() => { + // This reducer closes over a value from props. If the reducer is not + // properly updated, the eager reducer will compare to an old value + // and bail out incorrectly. + Scheduler.unstable_yieldValue('Reducer: ' + count); + return count; + }, -1); + useEffect(() => { + Scheduler.unstable_yieldValue('Effect: ' + count); + dispatch(); + }, [count]); + Scheduler.unstable_yieldValue('Render: ' + state); + return count; + } + + act(() => { + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: -1', + 'Effect: 1', + 'Reducer: 1', + 'Reducer: 1', + 'Render: 1', + ]); + expect(ReactNoop).toMatchRenderedOutput('1'); + }); + + act(() => { + setCounter(2); + }); + expect(Scheduler).toHaveYielded([ + 'Render: 1', + 'Effect: 2', + 'Reducer: 2', + 'Reducer: 2', + 'Render: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput('2'); + }); + + // Regression test. Covers a case where an internal state variable + // (`didReceiveUpdate`) is not reset properly. + it('state bail out edge case (#16359)', async () => { + let setCounterA; + let setCounterB; + + function CounterA() { + const [counter, setCounter] = useState(0); + setCounterA = setCounter; + Scheduler.unstable_yieldValue('Render A: ' + counter); + useEffect(() => { + Scheduler.unstable_yieldValue('Commit A: ' + counter); + }); + return counter; + } + + function CounterB() { + const [counter, setCounter] = useState(0); + setCounterB = setCounter; + Scheduler.unstable_yieldValue('Render B: ' + counter); + useEffect(() => { + Scheduler.unstable_yieldValue('Commit B: ' + counter); + }); + return counter; + } + + const root = ReactNoop.createRoot(null); + await ReactNoop.act(async () => { + root.render( + <> + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'Render A: 0', + 'Render B: 0', + 'Commit A: 0', + 'Commit B: 0', + ]); + + await ReactNoop.act(async () => { + setCounterA(1); + + // In the same batch, update B twice. To trigger the condition we're + // testing, the first update is necessary to bypass the early + // bailout optimization. + setCounterB(1); + setCounterB(0); + }); + expect(Scheduler).toHaveYielded([ + 'Render A: 1', + 'Render B: 0', + 'Commit A: 1', + // B should not fire an effect because the update bailed out + // 'Commit B: 0', + ]); + }); + + it('should update latest rendered reducer when a preceding state receives a render phase update', () => { + // Similar to previous test, except using a preceding render phase update + // instead of new props. + let dispatch; + function App() { + const [step, setStep] = useState(0); + const [shadow, _dispatch] = useReducer(() => step, step); + dispatch = _dispatch; + + if (step < 5) { + setStep(step + 1); + } + + Scheduler.unstable_yieldValue(`Step: ${step}, Shadow: ${shadow}`); + return shadow; + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Step: 0, Shadow: 0', + 'Step: 1, Shadow: 0', + 'Step: 2, Shadow: 0', + 'Step: 3, Shadow: 0', + 'Step: 4, Shadow: 0', + 'Step: 5, Shadow: 0', + ]); + expect(ReactNoop).toMatchRenderedOutput('0'); + + act(() => dispatch()); + expect(Scheduler).toHaveYielded(['Step: 5, Shadow: 5']); + expect(ReactNoop).toMatchRenderedOutput('5'); + }); + + it('should process the rest pending updates after a render phase update', () => { + // Similar to previous test, except using a preceding render phase update + // instead of new props. + let updateA; + let updateC; + function App() { + const [a, setA] = useState(false); + const [b, setB] = useState(false); + if (a !== b) { + setB(a); + } + // Even though we called setB above, + // we should still apply the changes to C, + // during this render pass. + const [c, setC] = useState(false); + updateA = setA; + updateC = setC; + return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`; + } + + act(() => ReactNoop.render()); + expect(ReactNoop).toMatchRenderedOutput('abc'); + + act(() => { + updateA(true); + // This update should not get dropped. + updateC(true); + }); + expect(ReactNoop).toMatchRenderedOutput('ABC'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 530544d6ca..7107a87c3e 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -9,95 +9,1495 @@ let Suspense; let TextResource; let textResourceShouldFail; -function loadModules({ - deferPassiveEffectCleanupDuringUnmount, - runAllPassiveEffectDestroysBeforeCreates, -}) { - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; - ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates; - React = require('react'); - Fragment = React.Fragment; - ReactNoop = require('react-noop-renderer'); - Scheduler = require('scheduler'); - ReactCache = require('react-cache'); - Suspense = React.Suspense; +describe('ReactSuspenseWithNoopRenderer', () => { + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + return; + } - TextResource = ReactCache.unstable_createResource( - ([text, ms = 0]) => { - return new Promise((resolve, reject) => - setTimeout(() => { - if (textResourceShouldFail) { - Scheduler.unstable_yieldValue(`Promise rejected [${text}]`); - reject(new Error('Failed to load: ' + text)); - } else { - Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); - resolve(text); - } - }, ms), + beforeEach(() => { + jest.resetModules(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + React = require('react'); + Fragment = React.Fragment; + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + ReactCache = require('react-cache'); + Suspense = React.Suspense; + + TextResource = ReactCache.unstable_createResource( + ([text, ms = 0]) => { + return new Promise((resolve, reject) => + setTimeout(() => { + if (textResourceShouldFail) { + Scheduler.unstable_yieldValue(`Promise rejected [${text}]`); + reject(new Error('Failed to load: ' + text)); + } else { + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); + resolve(text); + } + }, ms), + ); + }, + ([text, ms]) => text, + ); + textResourceShouldFail = false; + }); + + // function div(...children) { + // children = children.map( + // c => (typeof c === 'string' ? {text: c, hidden: false} : c), + // ); + // return {type: 'div', children, prop: undefined, hidden: false}; + // } + + function span(prop) { + return {type: 'span', children: [], prop, hidden: false}; + } + + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; + } + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); + } + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; + } + + function AsyncText(props) { + const text = props.text; + try { + TextResource.read([props.text, props.ms]); + Scheduler.unstable_yieldValue(text); + return ; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; + } + } + + it('does not restart rendering for initial render', async () => { + function Bar(props) { + Scheduler.unstable_yieldValue('Bar'); + return props.children; + } + + function Foo() { + Scheduler.unstable_yieldValue('Foo'); + return ( + <> + }> + + + + + + + + ); - }, - ([text, ms]) => text, - ); - textResourceShouldFail = false; -} + } -[true, false].forEach(deferPassiveEffectCleanupDuringUnmount => { - [true, false].forEach(runAllPassiveEffectDestroysBeforeCreates => { - describe(`ReactSuspenseWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; + ReactNoop.render(); + expect(Scheduler).toFlushAndYieldThrough([ + 'Foo', + 'Bar', + // A suspends + 'Suspend! [A]', + // But we keep rendering the siblings + 'B', + 'Loading...', + 'C', + // We leave D incomplete. + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush the promise completely + Scheduler.unstable_advanceTime(100); + await advanceTimers(100); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + + // Even though the promise has resolved, we should now flush + // and commit the in progress render instead of restarting. + expect(Scheduler).toFlushAndYield(['D']); + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('C'), + span('D'), + ]); + + // Await one micro task to attach the retry listeners. + await null; + + // Next, we'll flush the complete content. + expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']); + + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('B'), + span('C'), + span('D'), + ]); + }); + + it('suspends rendering and continues later', async () => { + function Bar(props) { + Scheduler.unstable_yieldValue('Bar'); + return props.children; + } + + function Foo({renderBar}) { + Scheduler.unstable_yieldValue('Foo'); + return ( + }> + {renderBar ? ( + + + + + ) : null} + + ); + } + + // Render empty shell. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo']); + + // The update will suspend. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Foo', + 'Bar', + // A suspends + 'Suspend! [A]', + // But we keep rendering the siblings + 'B', + 'Loading...', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush some of the time + await advanceTimers(50); + // Still nothing... + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush the promise completely + await advanceTimers(50); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('suspends siblings and later recovers each independently', async () => { + // Render two sibling Suspense components + ReactNoop.render( + + }> + + + }> + + + , + ); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + 'Loading A...', + 'Suspend! [B]', + 'Loading B...', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Loading A...'), + span('Loading B...'), + ]); + + // Advance time by enough that the first Suspense's promise resolves and + // switches back to the normal view. The second Suspense should still + // show the placeholder + ReactNoop.expire(5000); + await advanceTimers(5000); + + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading B...')]); + + // Advance time by enough that the second Suspense's promise resolves + // and switches back to the normal view + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('continues rendering siblings after suspending', async () => { + // A shell is needed. The update cause it to suspend. + ReactNoop.render(} />); + expect(Scheduler).toFlushAndYield([]); + // B suspends. Continue rendering the remaining siblings. + ReactNoop.render( + }> + + + + + , + ); + // B suspends. Continue rendering the remaining siblings. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Suspend! [B]', + 'C', + 'D', + 'Loading...', + ]); + // Did not commit yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Wait for data to resolve + await advanceTimers(100); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C', 'D']); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('B'), + span('C'), + span('D'), + ]); + }); + + it('retries on error', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); } - - beforeEach(() => { - jest.resetModules(); - - loadModules({ - deferPassiveEffectCleanupDuringUnmount, - runAllPassiveEffectDestroysBeforeCreates, - }); - }); - - // function div(...children) { - // children = children.map( - // c => (typeof c === 'string' ? {text: c, hidden: false} : c), - // ); - // return {type: 'div', children, prop: undefined, hidden: false}; - // } - - function span(prop) { - return {type: 'span', children: [], prop, hidden: false}; + reset() { + this.setState({error: null}); } - - function hiddenSpan(prop) { - return {type: 'span', children: [], prop, hidden: true}; - } - - function advanceTimers(ms) { - // Note: This advances Jest's virtual time but not React's. Use - // ReactNoop.expire for that. - if (typeof ms !== 'number') { - throw new Error('Must specify ms'); + render() { + if (this.state.error !== null) { + return ; } - jest.advanceTimersByTime(ms); - // Wait until the end of the current tick - // We cannot use a timer since we're faking them - return Promise.resolve().then(() => {}); + return this.props.children; + } + } + + const errorBoundary = React.createRef(); + function App({renderContent}) { + return ( + }> + {renderContent ? ( + + + + ) : null} + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + textResourceShouldFail = true; + ReactNoop.expire(1000); + await advanceTimers(1000); + textResourceShouldFail = false; + + expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); + + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', + + // React retries one more time + 'Error! [Result]', + + // Errored again on retry. Now handle it. + 'Caught error: Failed to load: Result', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Caught error: Failed to load: Result'), + ]); + }); + + it('retries on error after falling back to a placeholder', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + reset() { + this.setState({error: null}); + } + render() { + if (this.state.error !== null) { + return ; + } + return this.props.children; + } + } + + const errorBoundary = React.createRef(); + function App() { + return ( + }> + + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + textResourceShouldFail = true; + ReactNoop.expire(3000); + await advanceTimers(3000); + textResourceShouldFail = false; + + expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', + + // React retries one more time + 'Error! [Result]', + + // Errored again on retry. Now handle it. + + 'Caught error: Failed to load: Result', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Caught error: Failed to load: Result'), + ]); + }); + + it('can update at a higher priority while in a suspended state', async () => { + function App(props) { + return ( + }> + + + + ); + } + + // Initial mount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'Suspend! [1]', 'Loading...']); + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [1]']); + expect(Scheduler).toFlushAndYield(['A', '1']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]); + + // Update the low-pri text + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'A', + // Suspends + 'Suspend! [2]', + 'Loading...', + ]); + + // While we're still waiting for the low-pri update to complete, update the + // high-pri text at high priority. + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded(['B', '1']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + + // Unblock the low-pri text and finish + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [2]']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + }); + + it('keeps working on lower priority work after being pinged', async () => { + // Advance the virtual time so that we're close to the edge of a bucket. + ReactNoop.expire(149); + + function App(props) { + return ( + }> + {props.showA && } + {props.showB && } + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance React's virtual time by enough to fall into a new async bucket, + // but not enough to expire the suspense timeout. + ReactNoop.expire(120); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => { + function App(props) { + if (props.hide) { + return ; + } + return ( + + + + ); + } + + // Schedule a high pri update and a low pri update, without rendering in + // between. + ReactNoop.discreteUpdates(() => { + // High pri + ReactNoop.render(); + }); + // Low pri + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield([ + // The first update suspends + 'Suspend! [Async]', + // but we have another pending update that we can work on + '(empty)', + ]); + expect(ReactNoop.getChildren()).toEqual([span('(empty)')]); + }); + + it('tries each subsequent level after suspending', async () => { + const root = ReactNoop.createRoot(); + + function App({step, shouldSuspend}) { + return ( + + + {shouldSuspend ? ( + + ) : ( + + )} + + ); + } + + function interrupt() { + // React has a heuristic to batch all updates that occur within the same + // event. This is a trick to circumvent that heuristic. + ReactNoop.flushSync(() => { + ReactNoop.renderToRootWithID(null, 'other-root'); + }); + } + + // Mount the Suspense boundary without suspending, so that the subsequent + // updates suspend with a delay. + await ReactNoop.act(async () => { + root.render(); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']); + + // Schedule an update at several distinct expiration times + await ReactNoop.act(async () => { + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + }); + + // Should suspend at each distinct level + expect(Scheduler).toHaveYielded([ + 'Sibling', + 'Suspend! [Step 1]', + 'Sibling', + 'Suspend! [Step 2]', + 'Sibling', + 'Suspend! [Step 3]', + 'Sibling', + 'Step 4', + ]); + }); + + it('forces an expiration after an update times out', async () => { + ReactNoop.render( + + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( + + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + // The async child suspends + 'Suspend! [Async]', + // Render the placeholder + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to expire + // the update, but not by enough to flush the suspending promise. + ReactNoop.expire(10000); + await advanceTimers(10000); + // No additional rendering work is required, since we already prepared + // the placeholder. + expect(Scheduler).toHaveYielded([]); + // Should have committed the placeholder. + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(10000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('switches to an inner fallback after suspending for a while', async () => { + // Advance the virtual time so that we're closer to the edge of a bucket. + ReactNoop.expire(200); + + ReactNoop.render( + + + }> + + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'Sync', + // The async content suspends + 'Suspend! [Outer content]', + 'Suspend! [Inner content]', + 'Loading inner...', + 'Loading outer...', + ]); + // The outer loading state finishes immediately. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Loading outer...'), + ]); + + // Resolve the outer promise. + ReactNoop.expire(300); + await advanceTimers(300); + expect(Scheduler).toHaveYielded(['Promise resolved [Outer content]']); + expect(Scheduler).toFlushAndYield([ + 'Outer content', + 'Suspend! [Inner content]', + 'Loading inner...', + ]); + // Don't commit the inner placeholder yet. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Loading outer...'), + ]); + + // Expire the inner timeout. + ReactNoop.expire(500); + await advanceTimers(500); + // Now that 750ms have elapsed since the outer placeholder timed out, + // we can timeout the inner placeholder. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Outer content'), + span('Loading inner...'), + ]); + + // Finally, flush the inner promise. We should see the complete screen. + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [Inner content]']); + expect(Scheduler).toFlushAndYield(['Inner content']); + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Outer content'), + span('Inner content'), + ]); + }); + + it('renders an expiration boundary synchronously', async () => { + spyOnDev(console, 'error'); + // Synchronously render a tree that suspends + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + + + + , + ), + ); + expect(Scheduler).toHaveYielded([ + // The async child suspends + 'Suspend! [Async]', + // We immediately render the fallback UI + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('suspending inside an expired expiration boundary will bubble to the next one', async () => { + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + }> + + + + + , + ), + ); + expect(Scheduler).toHaveYielded([ + 'Suspend! [Async]', + 'Suspend! [Loading (inner)...]', + 'Sync', + 'Loading (outer)...', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([span('Loading (outer)...')]); + }); + + it('expires early by default', async () => { + ReactNoop.render( + + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( + + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + // The async child suspends + 'Suspend! [Async]', + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to trigger + // the timeout, but not by enough to flush the promise or reach the true + // expiration time. + ReactNoop.expire(2000); + await advanceTimers(2000); + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('resolves successfully even if fallback render is pending', async () => { + ReactNoop.render( + <> + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); + ReactNoop.render( + <> + }> + + + , + ); + expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']); + await advanceTimers(1500); + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop.getChildren()).toEqual([]); + // Before we have a chance to flush, the promise resolves. + await advanceTimers(2000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield([ + // We've now pinged the boundary but we don't know if we should restart yet, + // because we haven't completed the suspense boundary. + 'Loading...', + // Once we've completed the boundary we restarted. + 'Async', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + }); + + it('throws a helpful error when an update is suspends without a placeholder', () => { + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'AsyncText suspended while rendering, but no fallback UI was specified.', + ); + }); + + it('a Suspense component correctly handles more than one suspended child', async () => { + ReactNoop.render( + }> + + + , + ); + Scheduler.unstable_advanceTime(10000); + expect(Scheduler).toFlushExpired([ + 'Suspend! [A]', + 'Suspend! [B]', + 'Loading...', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + await advanceTimers(100); + + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + ]); + expect(Scheduler).toFlushAndYield(['A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('can resume rendering earlier than a timeout', async () => { + ReactNoop.render(} />); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( + }> + + , + ); + expect(Scheduler).toFlushAndYield(['Suspend! [Async]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance time by an amount slightly smaller than what's necessary to + // resolve the promise + await advanceTimers(99); + + // Nothing has rendered yet + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Resolve the promise + await advanceTimers(1); + // We can now resume rendering + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + }); + + it('starts working on an update even if its priority falls between two suspended levels', async () => { + function App(props) { + return ( + }> + {props.text === 'C' || props.text === 'S' ? ( + + ) : ( + + )} + + ); + } + + // First mount without suspending. This ensures we already have content + // showing so that subsequent updates will suspend. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['S']); + + // Schedule an update, and suspend for up to 5 seconds. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 5000, + }, + ); + // The update should suspend. + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Advance time until right before it expires. + await advanceTimers(4999); + ReactNoop.expire(4999); + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Schedule another low priority update. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 10000, + }, + ); + // This update should also suspend. + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Schedule a regular update. Its expiration time will fall between + // the expiration times of the previous two updates. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['C']); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + + await advanceTimers(10000); + // Flush the remaining work. + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + ]); + // Nothing else to render. + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + }); + + it('flushes all expired updates in a single batch', async () => { + class Foo extends React.Component { + componentDidUpdate() { + Scheduler.unstable_yieldValue('Commit: ' + this.props.text); + } + componentDidMount() { + Scheduler.unstable_yieldValue('Commit: ' + this.props.text); + } + render() { + return ( + }> + + + ); + } + } + + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + + Scheduler.unstable_advanceTime(10000); + jest.advanceTimersByTime(10000); + + expect(Scheduler).toFlushExpired([ + 'Suspend! [goodbye]', + 'Loading...', + 'Commit: goodbye', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + Scheduler.unstable_advanceTime(20000); + await advanceTimers(20000); + expect(Scheduler).toHaveYielded(['Promise resolved [goodbye]']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + expect(Scheduler).toFlushAndYield(['goodbye']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); + + it('a suspended update that expires', async () => { + // Regression test. This test used to fall into an infinite loop. + function ExpensiveText({text}) { + // This causes the update to expire. + Scheduler.unstable_advanceTime(10000); + // Then something suspends. + return ; + } + + function App() { + return ( + + + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + 'Suspend! [B]', + 'Suspend! [C]', + ]); + expect(ReactNoop).toMatchRenderedOutput('Loading...'); + + await advanceTimers(200000); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + 'Promise resolved [C]', + ]); + + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + + , + ); + }); + + describe('legacy mode mode', () => { + it('times out immediately', async () => { + function App() { + return ( + }> + + + ); } - function Text(props) { - Scheduler.unstable_yieldValue(props.text); - return ; + // Times out immediately, ignoring the specified threshold. + ReactNoop.renderLegacySyncRoot(); + expect(Scheduler).toHaveYielded(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + ReactNoop.expire(100); + await advanceTimers(100); + + expect(Scheduler).toHaveYielded(['Promise resolved [Result]']); + expect(Scheduler).toFlushExpired(['Result']); + expect(ReactNoop.getChildren()).toEqual([span('Result')]); + }); + + it('times out immediately when Suspense is in legacy mode', async () => { + class UpdatingText extends React.Component { + state = {step: 1}; + render() { + return ; + } } - function AsyncText(props) { - const text = props.text; + function Spinner() { + return ( + + + + + + ); + } + + const text = React.createRef(null); + function App() { + return ( + }> + + + + ); + } + + // Initial mount. + ReactNoop.renderLegacySyncRoot(); + await advanceTimers(100); + expect(Scheduler).toHaveYielded([ + 'Suspend! [Step: 1]', + 'Sibling', + 'Loading (1)', + 'Loading (2)', + 'Loading (3)', + 'Promise resolved [Step: 1]', + ]); + expect(Scheduler).toFlushExpired(['Step: 1']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Update. + text.current.setState({step: 2}, () => + Scheduler.unstable_yieldValue('Update did commit'), + ); + + expect(ReactNoop.flushNextYield()).toEqual([ + 'Suspend! [Step: 2]', + 'Loading (1)', + 'Loading (2)', + 'Loading (3)', + 'Update did commit', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> +