From 15c91fc4a457b4213415d01b4dca8f6c1b86a608 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 12 Feb 2020 15:05:58 -0800 Subject: [PATCH 1/2] Split recent passive effects changes into 2 flags Separate flags can now be used to opt passive effects into: 1) Deferring destroy functions on unmount to subsequent passive effects flush 2) Running all destroy functions (for all fibers) before create functions This allows us to test the less risky feature (2) separately from the more risky one. --- .../src/ReactFiberCommitWork.js | 5 +- .../src/ReactFiberWorkLoop.js | 8 +- ...eactHooksWithNoopRenderer-test.internal.js | 5149 ++++++++-------- ...tSuspenseWithNoopRenderer-test.internal.js | 5468 +++++++++-------- packages/shared/ReactFeatureFlags.js | 9 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 3 +- 12 files changed, 5471 insertions(+), 5177 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 9d9d220f33d..531a774551c 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -34,6 +34,7 @@ import { enableFundamentalAPI, enableSuspenseCallback, enableScopeAPI, + runAllPassiveEffectDestroysBeforeCreates, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -398,7 +399,7 @@ function commitHookEffectListMount(tag: number, finishedWork: Fiber) { } function schedulePassiveEffects(finishedWork: Fiber) { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; if (lastEffect !== null) { @@ -456,7 +457,7 @@ function commitLifeCycles( // by a create function in another component during the same commit. commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { schedulePassiveEffects(finishedWork); } return; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f647ccb88b2..116bb2e99cf 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -18,7 +18,7 @@ import type {Effect as HookEffect} from './ReactFiberHooks'; import { warnAboutDeprecatedLifecycles, - deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, enableUserTimingAPI, enableSuspenseServerRenderer, replayFailedUnitOfWorkWithInvokeGuardedCallback, @@ -2174,7 +2174,7 @@ export function enqueuePendingPassiveHookEffectMount( fiber: Fiber, effect: HookEffect, ): void { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { pendingPassiveHookEffectsMount.push(effect, fiber); if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects = true; @@ -2190,7 +2190,7 @@ export function enqueuePendingPassiveHookEffectUnmount( fiber: Fiber, effect: HookEffect, ): void { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { pendingPassiveHookEffectsUnmount.push(effect, fiber); if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects = true; @@ -2224,7 +2224,7 @@ function flushPassiveEffectsImpl() { executionContext |= CommitContext; const prevInteractions = pushInteractions(root); - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { // It's important that ALL pending passive effect destroy functions are called // before ANY passive effect create functions are called. // Otherwise effects in sibling components might interfere with each other. diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 438a748fb75..fa1890e40ab 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -34,2759 +34,2950 @@ let forwardRef; let memo; let act; -describe('ReactHooksWithNoopRenderer', () => { - beforeEach(() => { - jest.resetModules(); - jest.useFakeTimers(); - - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.enableSchedulerTracing = true; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = 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; - - 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); - }); +function loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, +}) { + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableSchedulerTracing = true; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; + ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = 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; +} + +[ + [true, true], + [false, true], + [false, false], +].forEach( + ([ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, + ]) => { + describe(`ReactHooksWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { + beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + + loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, + }); - // Partially flush without committing - expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + 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, + ); + }); - // 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 ; + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; } - 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 ; + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; } - 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.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 ; + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + 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']); - }); + 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('returns the same updater function every time', () => { - let updaters = []; - function Counter() { - const [count, updateCount] = useState(0); - updaters.push(updateCount); - return ; + 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(() => {}); } - 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')]); + it('resumes after an interruption', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); - act(() => updaters[0](count => count + 10)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + // Initial mount + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); - }); + // Schedule some updates + ReactNoop.batchedUpdates(() => { + counter.current.updateCount(1); + counter.current.updateCount(count => count + 10); + }); - it('warns on set after unmount', () => { - let _updateCount; - function Counter(props, ref) { - const [, updateCount] = useState(0); - _updateCount = updateCount; - return null; - } + // Partially flush without committing + expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - 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 **)', - ); - }); + // Interrupt with a high priority update + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded(['Total: 0']); - it('works with memo', () => { - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - return ; - } - Counter = memo(Counter); + // Resume rendering + expect(Scheduler).toFlushAndYield(['Total: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + 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.', + ); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + // Confirm that a subsequent hook works properly. + function GoodCounter(props, ref) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); - 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); + 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.", + ); - return ; - } + // Confirm that a subsequent hook works properly. + function GoodCounter(props) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + 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(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + 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')]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + 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.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + 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')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + act(() => counter.current.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); - }); + act(() => counter.current.updateLabel('Total')); + expect(Scheduler).toHaveYielded(['Total: 7']); + }); - 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 ; - } + 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')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); + act(() => updaters[0](1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - 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 ; - } + act(() => updaters[0](count => count + 10)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - 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)]); - }); + expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + }); - 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('warns on set after unmount', () => { + let _updateCount; + function Counter(props, ref) { + const [, updateCount] = useState(0); + _updateCount = updateCount; + return null; + } - 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).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 **)', + ); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); + it('works with memo', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + return ; + } + Counter = memo(Counter); - 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. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - // 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; - } - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 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'); + act(() => _updateCount(1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); }); - 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 ( - - - - ); - } + 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); - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); - let [signal, setSignal] = useState(true); + if (row !== newRow) { + // Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row !== null && newRow > row); + setRow(newRow); + } - // 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 ; } - } - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); - const root = ReactNoop.createRoot(); - root.render(); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - expect(Scheduler).toFlushAndYield([0]); - expect(root).toMatchRenderedOutput(); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - expect(root).toMatchRenderedOutput(); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - // Rendering again should suspend again. - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); - it('discards render phase updates if something suspends, but not other updates in the same component', async () => { - const thenable = {then() {}}; - function Foo({signal}) { - return ( - - - - ); - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); + }); - let setLabel; - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); + 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 ; + } - 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; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); - let [signal, setSignal] = useState(true); + 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 ; + } - // Increment a counter every time the signal changes - if (signal !== newSignal) { - setCounter(c => c + 1); - setSignal(newSignal); - } + 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)]); + }); - let [label, _setLabel] = useState('A'); - setLabel = _setLabel; + 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.', + ); + }); - return ; - } + 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 ; + } - const root = ReactNoop.createRoot(); - root.render(); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); - expect(Scheduler).toFlushAndYield(['A:0']); - expect(root).toMatchRenderedOutput(); + 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; + } + } - 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(); - }); + 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)]); - // 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); + // 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)]); + }); - return ; - } + it('discards render phase updates if something suspends', () => { + const thenable = {then() {}}; + function Foo({signal}) { + 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 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 ; + } - 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); - }); + const root = ReactNoop.createRoot(); + root.render(); - expect(Scheduler).toHaveYielded(['Count: -2']); - expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); - }); + expect(Scheduler).toFlushAndYield([0]); + expect(root).toMatchRenderedOutput(); - 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; - } - } + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + expect(root).toMatchRenderedOutput(); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, props, p => { - Scheduler.unstable_yieldValue('Init'); - return p.initialCount; + // Rendering again should suspend again. + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); }); - 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')]); - }); + it('discards render phase updates if something suspends, but not other updates in the same component', async () => { + const thenable = {then() {}}; + function Foo({signal}) { + return ( + + + + ); + } - // Regression test for https://github.com/facebook/react/issues/14360 - it('handles dispatches with mixed priorities', () => { - const INCREMENT = 'INCREMENT'; + let setLabel; + function Bar({signal: newSignal}) { + let [counter, setCounter] = useState(0); - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + 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; + } - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } + let [signal, setSignal] = useState(true); - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); + // Increment a counter every time the signal changes + if (signal !== newSignal) { + setCounter(c => c + 1); + setSignal(newSignal); + } - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + let [label, _setLabel] = useState('A'); + setLabel = _setLabel; - ReactNoop.batchedUpdates(() => { - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - }); + return ; + } - ReactNoop.flushSync(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + const root = ReactNoop.createRoot(); + root.render(); - expect(Scheduler).toFlushAndYield(['Count: 4']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 4')]); - }); - }); + expect(Scheduler).toFlushAndYield(['A:0']); + expect(root).toMatchRenderedOutput(); - describe('useEffect', () => { - it('simple mount and update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); + 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(); }); - 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'), + // 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(); + }, ); - 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([]); - }); + 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; + } + } - it('flushes passive effects even if siblings schedule an update', () => { - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Passive effect'); + 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')]); }); - 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); + + 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')]); }); - return ; - } - ReactNoop.render([, ]); + // Regression test for https://github.com/facebook/react/issues/14360 + it('handles dispatches with mixed priorities', () => { + const INCREMENT = 'INCREMENT'; - act(() => { - expect(Scheduler).toFlushAndYield([ - 'Passive', - 'Layout', - 'Layout effect 0', - 'Passive effect', - 'Layout', - 'Layout effect 1', - ]); - }); + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - expect(ReactNoop.getChildren()).toEqual([ - span('Passive'), - span('Layout'), - ]); - }); + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } - 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'); + 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')]); }); - 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; + describe('useEffect', () => { + it('simple mount and update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); + }); + return ; } - return children[0].prop; - } + 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]']); + }); - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue( - `Committed state when effect was fired: ${getCommittedText()}`, + 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]']); }); - 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', - ]); - }, - ); - - 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; - } + 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([]); + }); - 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 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 ; + } - // 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', - ]); - }); + ReactNoop.render([ + , + , + ]); - // 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', - ]); - }); - }); + act(() => { + expect(Scheduler).toFlushAndYield([ + 'Passive', + 'Layout', + 'Layout effect 0', + 'Passive effect', + 'Layout', + 'Layout effect 1', + ]); + }); - 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']); - }); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); + }); - 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('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('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)')]); + 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)]); + }); - // Rendering again should flush the previous commit's effects - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + expect(Scheduler).toHaveYielded([ + 'Committed state when effect was fired: 1', + ]); + }, ); - 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')]); - }); - }); + if (deferPassiveEffectCleanupDuringUnmount) { + 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', + ]); + }); - 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); + 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']); + }); }); - }); - 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(), - }); + 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')]); + }); + }); - 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 ; - } + 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 ; + } - 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, - () => { + // 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']); - - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); + 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', + ]); - // 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(ReactNoop.getChildren()).toEqual([span('Count: 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('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(), + }); - // 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); + 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, + () => { + 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); + }); }); - }, [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(['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('unmounts previous effect', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).toHaveBeenCalledTimes(0); }); - 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]']); + 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)')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + // 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')]); + }, ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - expect(Scheduler).toHaveYielded(['Did destroy [0]', 'Did create [1]']); - }); + 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'); + }); + }); - it('unmounts on deletion', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; + 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')]); + }); + + 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]', + ]); }); - 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]']); + 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')]); + }); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy [0]']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + expect(Scheduler).toHaveYielded(['Did create [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')]); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create [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')]); + }); - 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 create [0]']); - expect(Scheduler).toHaveYielded([]); + 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([]); - 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')]); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create']); + 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')]); + }); - 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 create']); - expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); + 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']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); - 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']); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create [Count: 0]']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 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', + ]); + }); - 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 create [Count: 0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 0]', - 'Did create [Count: 1]', - ]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Count changed + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('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([ + 'Did destroy [Count: 0]', + 'Did create [Count: 1]', + ]); - expect(Scheduler).toHaveYielded([]); - expect(ReactNoop.getChildren()).toEqual([span('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', + ]); + }); - 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([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 1]', - 'Did create [Total: 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')]); + }); - it('multiple effects', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 1 [${props.count}]`); - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 2 [${props.count}]`); + expect(Scheduler).toHaveYielded([ + 'Did destroy [Count: 1]', + 'Did create [Total: 1]', + ]); }); - 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]']); + 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')]); + }); - 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]']); - }); + expect(Scheduler).toHaveYielded([ + 'Did commit 1 [0]', + 'Did commit 2 [0]', + ]); - 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}]`); - }; + 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]', + ]); }); - 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]']); + 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')]); + }); - 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]', - ]); - }); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - 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}]`); - }; + 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]', + ]); }); - 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]']); + 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'), + ]); + }); - 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]', - ]); - }); + 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 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}]`); - }; + 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([]); }); - 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]']); + }); - it('handles errors 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}]`); - }; + 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 + ? ['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) { + 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]', + ]); + } }); - 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([ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Oops!', - ]); - expect(ReactNoop.getChildren()).toEqual([]); - }); - 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 ; + } - it('handles errors on unmount', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; + 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) { + 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(); + }); + } }); - 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]']); - }); + it('works with memo', () => { + function Counter({count}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount: ' + count); + return () => Scheduler.unstable_yieldValue('Unmount: ' + count); + }); + return ; + } + Counter = memo(Counter); - 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 tests 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]', - ]); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Mount: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + 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([]); + }); }); - // 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([]); - }); + 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 ; + } - it('works with memo', () => { - function Counter({count}) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue('Mount: ' + count); - return () => Scheduler.unstable_yieldValue('Unmount: ' + count); + 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)]); }); - 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')]); - - 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 ; - } + 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; + } - 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)]); - }); + 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'); + }); - it('force flushes passive effects before firing new layout effects', () => { - let committedText = '(empty)'; + expect(Scheduler).toHaveYielded([ + 'Unmount normal [current: 1]', + 'Mount normal [current: 1]', + ]); + }); + }); - function Counter(props) { - useLayoutEffect(() => { - // Normally this would go in a mutation effect, but this test - // intentionally omits a mutation effect. - committedText = props.count + ''; + describe('useCallback', () => { + it('memoizes callback by comparing inputs', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.increment(); + }; + render() { + return ; + } + } - Scheduler.unstable_yieldValue( - `Mount layout [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount layout [current: ${committedText}]`, + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useCallback( + () => updateCount(c => c + incrementBy), + [incrementBy], ); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue( - `Mount normal [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount normal [current: ${committedText}]`, + return ( + <> + + + ); - }; - }); - 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'); - }); + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); - 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 ; - } - } + 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'), + ]); - function Counter({incrementBy}) { - const [count, updateCount] = useState(0); - const increment = useCallback(() => updateCount(c => c + incrementBy), [ - incrementBy, - ]); - return ( - <> - - - - ); - } + // 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'), + ]); - 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'), - ]); - }); - }); + // 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 ; - } + 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 '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(["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(['HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'goodbye'", 'GOODBYE']); - expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); - }); + 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 ; - } + 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 computeA() { + Scheduler.unstable_yieldValue('compute A'); + return 'A'; + } - function computeB() { - Scheduler.unstable_yieldValue('compute B'); - return 'B'; - } + 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 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']); - }); + 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 ; - } + 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; - } + function compute(val) { + Scheduler.unstable_yieldValue('compute ' + val); + return val; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A']); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute B', 'B']); - }); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute B', 'B']); + }); + }); - describe('useRef', () => { - it('creates a ref object initialized with the provided value', () => { - jest.useFakeTimers(); + 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); + } - 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; + } - let ping; - function App() { - ping = useDebouncedCallback( - value => { - Scheduler.unstable_yieldValue('ping: ' + value); - }, - 100, - [], - ); - return null; - } + act(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([]); - act(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded([]); + ping(1); + ping(2); + ping(3); - ping(1); - ping(2); - ping(3); + expect(Scheduler).toHaveYielded([]); - expect(Scheduler).toHaveYielded([]); + jest.advanceTimersByTime(100); - jest.advanceTimersByTime(100); + expect(Scheduler).toHaveYielded(['ping: 3']); - expect(Scheduler).toHaveYielded(['ping: 3']); + ping(4); + jest.advanceTimersByTime(20); + ping(5); + ping(6); + jest.advanceTimersByTime(80); - ping(4); - jest.advanceTimersByTime(20); - ping(5); - ping(6); - jest.advanceTimersByTime(80); + expect(Scheduler).toHaveYielded([]); - expect(Scheduler).toHaveYielded([]); + jest.advanceTimersByTime(20); + expect(Scheduler).toHaveYielded(['ping: 6']); + }); - 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); - 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 (firstRef !== ref) { - throw new Error('should never change'); - } + if (count < 3) { + setCount(count + 1); + } - if (count < 3) { - setCount(count + 1); - } + return ; + } - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['val']); + + 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'; - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['val']); - }); - }); + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - describe('useImperativeHandle', () => { - it('does not update when deps are the same', () => { - const INCREMENT = 'INCREMENT'; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch}), []); + return ; + } - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + 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); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch}), []); - return ; - } + 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); + }); - 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); + // Regression test for https://github.com/facebook/react/issues/14782 + it('automatically updates when deps are not specified', () => { + const INCREMENT = 'INCREMENT'; - 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); - }); + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - // Regression test for https://github.com/facebook/react/issues/14782 - it('automatically updates when deps are not specified', () => { - const INCREMENT = 'INCREMENT'; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch})); + return ; + } - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + 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); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch})); - return ; - } + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + }); - 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); + it('updates when deps are different', () => { + const INCREMENT = 'INCREMENT'; - act(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - }); + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - it('updates when deps are different', () => { - const INCREMENT = 'INCREMENT'; + let totalRefUpdates = 0; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle( + ref, + () => { + totalRefUpdates++; + return {count, dispatch}; + }, + [count], + ); + return ; + } - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + 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); - let totalRefUpdates = 0; - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle( - ref, - () => { - totalRefUpdates++; - return {count, dispatch}; + 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'), + ]); }, - [count], ); - return ; - } + 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(); + }); - 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); + expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('Loading'), + ]); - 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); + 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'); }); - }; - return ( - }> - {show ? ( - - ) : ( - - )} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Before... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: false'), - ]); + expect(Scheduler).toHaveYielded([ + 'B', + 'A', + 'B', + 'Suspend! [B]', + 'Loading', + ]); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); - 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(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')]); + }, + ); + }); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(ReactNoop.getChildren()).toEqual([ - hiddenSpan('Before... Pending: true'), - span('Loading... Pending: false'), - ]); + 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 ; + } - 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, + 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); }); - 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'), - ]); + expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); - 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'), - ]); + 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', + ]); - 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 ; - } + // Uncomment if/when we support this again + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); - let _setText; - function App() { - const [text, setText] = useState('A'); - const deferredText = useDeferredValue(text, { - timeoutMs: 500, + // updateC(4); + // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']); + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); }); - _setText = setText; - return ( - <> - - }> - - - - ); - } - act(() => { - ReactNoop.render(); - }); + 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 ; + } - expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading')]); + 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.', + ); + }); - 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')]); + 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'); + }; + }, []); + } - 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 null; + } - return ; - } + 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', + ]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: [not loaded]']); - expect(ReactNoop.getChildren()).toEqual([ - span('A: 0, B: 0, C: [not loaded]'), - ]); + // Uncomment if/when we support this again + // ReactNoop.flushPassiveEffects(); + // expect(Scheduler).toHaveYielded(['Mount B']); - act(() => { - updateA(2); - updateB(3); + // ReactNoop.render(); + // expect(Scheduler).toFlushAndThrow( + // 'Rendered fewer hooks than expected. This may be caused by an ' + + // 'accidental early return statement.', + // ); + }); }); - 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('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 ; + } - 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]'; + 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; } - return ; - } + act(() => { + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: -1', + 'Effect: 1', + 'Reducer: 1', + 'Reducer: 1', + 'Render: 1', + ]); + expect(ReactNoop).toMatchRenderedOutput('1'); + }); - 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); + act(() => { + setCounter(2); + }); + expect(Scheduler).toHaveYielded([ + 'Render: 1', + 'Effect: 2', + 'Reducer: 2', + 'Reducer: 2', + 'Render: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput('2'); }); - 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'); - }; - }, []); + // 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; - if (props.showMore) { + function CounterA() { + const [counter, setCounter] = useState(0); + setCounterA = setCounter; + Scheduler.unstable_yieldValue('Render A: ' + counter); useEffect(() => { - Scheduler.unstable_yieldValue('Mount B'); - return () => { - Scheduler.unstable_yieldValue('Unmount B'); - }; - }, []); + Scheduler.unstable_yieldValue('Commit A: ' + counter); + }); + return counter; } - return null; - } + function CounterB() { + const [counter, setCounter] = useState(0); + setCounterB = setCounter; + Scheduler.unstable_yieldValue('Render B: ' + counter); + useEffect(() => { + Scheduler.unstable_yieldValue('Commit B: ' + counter); + }); + return counter; + } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); - }); + 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', + ]); - expect(Scheduler).toHaveYielded(['Mount A']); + await ReactNoop.act(async () => { + setCounterA(1); - 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', + // 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', ]); }); - // Uncomment if/when we support this again - // ReactNoop.flushPassiveEffects(); - // expect(Scheduler).toHaveYielded(['Mount B']); + 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; - // 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'); - }); + if (step < 5) { + setStep(step + 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); + 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'); }); - 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); + 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'); + }); }); - 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 71dd2c8df71..9047658f50b 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -9,1230 +9,1592 @@ let Suspense; let TextResource; let textResourceShouldFail; -describe('ReactSuspenseWithNoopRenderer', () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - - beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = true; - 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('warns if the deprecated maxDuration option is used', () => { - function Foo() { - return ( - -
; - - ); - } - - ReactNoop.render(); - - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'Warning: maxDuration has been removed from React. ' + - 'Remove the maxDuration prop.' + - '\n in Suspense (at **)' + - '\n in Foo (at **)', - ]); - }); - - 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 ( - <> - }> - - - - - - - - - ); - } - - 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} - +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; + + 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), ); - } - - // 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}); + }, + ([text, ms]) => text, + ); + textResourceShouldFail = false; +} + +[ + [true, true], + [false, true], + [false, false], +].forEach( + ([ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, + ]) => { + describe(`ReactSuspenseWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + return; } - reset() { - this.setState({error: null}); + + 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}; } - render() { - if (this.state.error !== null) { - return ; - } - return this.props.children; + + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; } - } - - 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}); + + 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(() => {}); } - reset() { - this.setState({error: null}); + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; } - render() { - if (this.state.error !== null) { - 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; } - 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 ? ( - - ) : ( - - )} - - ); - } + it('warns if the deprecated maxDuration option is used', () => { + function Foo() { + return ( + +
; + + ); + } + + ReactNoop.render(); + + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ + 'Warning: maxDuration has been removed from React. ' + + 'Remove the maxDuration prop.' + + '\n in Suspense (at **)' + + '\n in Foo (at **)', + ]); + }); + + 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 ( + <> + }> + + + + + + + + + ); + } + + 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}); + } + reset() { + this.setState({error: null}); + } + render() { + if (this.state.error !== null) { + return ( + + ); + } + 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 ( + }> + + + ); + } + + // 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 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( + <> +