diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 917545b03d510..dc0e80e7edbba 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -418,25 +418,6 @@ function updateForwardRef( renderLanes, ); hasId = checkDidRenderIdHook(); - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - nextChildren = renderWithHooks( - current, - workInProgress, - render, - nextProps, - ref, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } setIsRendering(false); } else { nextChildren = renderWithHooks( @@ -1125,25 +1106,6 @@ function updateFunctionComponent( renderLanes, ); hasId = checkDidRenderIdHook(); - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - nextChildren = renderWithHooks( - current, - workInProgress, - Component, - nextProps, - context, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } setIsRendering(false); } else { nextChildren = renderWithHooks( @@ -1969,26 +1931,6 @@ function mountIndeterminateComponent( getComponentNameFromType(Component) || 'Unknown', ); } - - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - value = renderWithHooks( - null, - workInProgress, - Component, - props, - context, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } } if (getIsHydrating() && hasId) { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 941328bd6274d..d26036c4553ed 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -418,25 +418,6 @@ function updateForwardRef( renderLanes, ); hasId = checkDidRenderIdHook(); - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - nextChildren = renderWithHooks( - current, - workInProgress, - render, - nextProps, - ref, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } setIsRendering(false); } else { nextChildren = renderWithHooks( @@ -1125,25 +1106,6 @@ function updateFunctionComponent( renderLanes, ); hasId = checkDidRenderIdHook(); - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - nextChildren = renderWithHooks( - current, - workInProgress, - Component, - nextProps, - context, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } setIsRendering(false); } else { nextChildren = renderWithHooks( @@ -1969,26 +1931,6 @@ function mountIndeterminateComponent( getComponentNameFromType(Component) || 'Unknown', ); } - - if ( - debugRenderPhaseSideEffectsForStrictMode && - workInProgress.mode & StrictLegacyMode - ) { - setIsStrictModeForDevtools(true); - try { - value = renderWithHooks( - null, - workInProgress, - Component, - props, - context, - renderLanes, - ); - hasId = checkDidRenderIdHook(); - } finally { - setIsStrictModeForDevtools(false); - } - } } if (getIsHydrating() && hasId) { diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index c99c9a3407ce1..af4743e4809cc 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -41,6 +41,7 @@ import { enableUseMemoCacheHook, enableUseEventHook, enableLegacyCache, + debugRenderPhaseSideEffectsForStrictMode, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -53,6 +54,7 @@ import { ConcurrentMode, DebugTracingMode, StrictEffectsMode, + StrictLegacyMode, } from './ReactTypeOfMode'; import { NoLane, @@ -121,7 +123,10 @@ import { warnAboutMultipleRenderersDEV, } from './ReactMutableSource.new'; import {logStateUpdateScheduled} from './DebugTracing'; -import {markStateUpdateScheduled} from './ReactFiberDevToolsHook.new'; +import { + markStateUpdateScheduled, + setIsStrictModeForDevtools, +} from './ReactFiberDevToolsHook.new'; import {createCache} from './ReactFiberCacheComponent.new'; import { createUpdate as createLegacyQueueUpdate, @@ -140,6 +145,7 @@ import { trackUsedThenable, checkIfUseWrappedInTryCatch, } from './ReactFiberThenable.new'; +import type {ThenableState} from './ReactFiberThenable.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -236,6 +242,7 @@ let didScheduleRenderPhaseUpdate: boolean = false; // TODO: Maybe there's some way to consolidate this with // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`. let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; +let shouldDoubleInvokeUserFnsInHooksDEV: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; // Counts number of `use`-d thenables @@ -473,50 +480,69 @@ export function renderWithHooks( // If this is a replay, restore the thenable state from the previous attempt. const prevThenableState = getSuspendedThenableState(); prepareThenableState(prevThenableState); + + // In Strict Mode, during development, user functions are double invoked to + // help detect side effects. The logic for how this is implemented for in + // hook components is a bit complex so let's break it down. + // + // We will invoke the entire component function twice. However, during the + // second invocation of the component, the hook state from the first + // invocation will be reused. That means things like `useMemo` functions won't + // run again, because the deps will match and the memoized result will + // be reused. + // + // We want memoized functions to run twice, too, so account for this, user + // functions are double invoked during the *first* invocation of the component + // function, and are *not* double invoked during the second incovation: + // + // - First execution of component function: user functions are double invoked + // - Second execution of component function (in Strict Mode, during + // development): user functions are not double invoked. + // + // This is intentional for a few reasons; most importantly, it's because of + // how `use` works when something suspends: it reuses the promise that was + // passed during the first attempt. This is itself a form of memoization. + // We need to be able to memoize the reactive inputs to the `use` call using + // a hook (i.e. `useMemo`), which means, the reactive inputs to `use` must + // come from the same component invocation as the output. + // + // There are plenty of tests to ensure this behavior is correct. + const shouldDoubleRenderDEV = + __DEV__ && + debugRenderPhaseSideEffectsForStrictMode && + (workInProgress.mode & StrictLegacyMode) !== NoMode; + + shouldDoubleInvokeUserFnsInHooksDEV = shouldDoubleRenderDEV; let children = Component(props, secondArg); + shouldDoubleInvokeUserFnsInHooksDEV = false; // Check if there was a render phase update if (didScheduleRenderPhaseUpdateDuringThisPass) { - // Keep rendering in a loop for as long as render phase updates continue to - // be scheduled. Use a counter to prevent infinite loops. - let numberOfReRenders: number = 0; - do { - didScheduleRenderPhaseUpdateDuringThisPass = false; - localIdCounter = 0; - thenableIndexCounter = 0; - - if (numberOfReRenders >= RE_RENDER_LIMIT) { - throw new Error( - 'Too many re-renders. React limits the number of renders to prevent ' + - 'an infinite loop.', - ); - } - - numberOfReRenders += 1; - if (__DEV__) { - // Even when hot reloading, allow dependencies to stabilize - // after first render to prevent infinite render phase updates. - ignorePreviousDependencies = false; - } - - // Start over from the beginning of the list - currentHook = null; - workInProgressHook = null; - - workInProgress.updateQueue = null; - - if (__DEV__) { - // Also validate hook order for cascading updates. - hookTypesUpdateIndexDev = -1; - } - - ReactCurrentDispatcher.current = __DEV__ - ? HooksDispatcherOnRerenderInDEV - : HooksDispatcherOnRerender; + // Keep rendering until the component stabilizes (there are no more render + // phase updates). + children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + } - prepareThenableState(prevThenableState); - children = Component(props, secondArg); - } while (didScheduleRenderPhaseUpdateDuringThisPass); + if (shouldDoubleRenderDEV) { + // In development, components are invoked twice to help detect side effects. + setIsStrictModeForDevtools(true); + try { + children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + } finally { + setIsStrictModeForDevtools(false); + } } // We can assume the previous dispatcher is always this one, since we set it @@ -616,6 +642,65 @@ export function renderWithHooks( return children; } +function renderWithHooksAgain( + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, + prevThenableState: ThenableState | null, +) { + // This is used to perform another render pass. It's used when setState is + // called during render, and for double invoking components in Strict Mode + // during development. + // + // The state from the previous pass is reused whenever possible. So, state + // updates that were already processed are not processed again, and memoized + // functions (`useMemo`) are not invoked again. + // + // Keep rendering in a loop for as long as render phase updates continue to + // be scheduled. Use a counter to prevent infinite loops. + let numberOfReRenders: number = 0; + let children; + do { + didScheduleRenderPhaseUpdateDuringThisPass = false; + localIdCounter = 0; + thenableIndexCounter = 0; + + if (numberOfReRenders >= RE_RENDER_LIMIT) { + throw new Error( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + } + + numberOfReRenders += 1; + if (__DEV__) { + // Even when hot reloading, allow dependencies to stabilize + // after first render to prevent infinite render phase updates. + ignorePreviousDependencies = false; + } + + // Start over from the beginning of the list + currentHook = null; + workInProgressHook = null; + + workInProgress.updateQueue = null; + + if (__DEV__) { + // Also validate hook order for cascading updates. + hookTypesUpdateIndexDev = -1; + } + + ReactCurrentDispatcher.current = __DEV__ + ? HooksDispatcherOnRerenderInDEV + : HooksDispatcherOnRerender; + + prepareThenableState(prevThenableState); + children = Component(props, secondArg); + } while (didScheduleRenderPhaseUpdateDuringThisPass); + return children; +} + export function checkDidRenderIdHook(): boolean { // This should be called immediately after every renderWithHooks call. // Conceptually, it's part of the return value of renderWithHooks; it's only a @@ -1023,12 +1108,15 @@ function updateReducer( } // Process this update. + const action = update.action; + if (shouldDoubleInvokeUserFnsInHooksDEV) { + reducer(newState, action); + } if (update.hasEagerState) { // If this update is a state update (not a reducer) and was processed eagerly, // we can use the eagerly computed state newState = ((update.eagerState: any): S); } else { - const action = update.action; newState = reducer(newState, action); } } @@ -2110,6 +2198,9 @@ function mountMemo( ): T { const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; + if (shouldDoubleInvokeUserFnsInHooksDEV) { + nextCreate(); + } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; @@ -2131,6 +2222,9 @@ function updateMemo( } } } + if (shouldDoubleInvokeUserFnsInHooksDEV) { + nextCreate(); + } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 38ce674d4c273..144a5d7a93a9e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -41,6 +41,7 @@ import { enableUseMemoCacheHook, enableUseEventHook, enableLegacyCache, + debugRenderPhaseSideEffectsForStrictMode, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -53,6 +54,7 @@ import { ConcurrentMode, DebugTracingMode, StrictEffectsMode, + StrictLegacyMode, } from './ReactTypeOfMode'; import { NoLane, @@ -121,7 +123,10 @@ import { warnAboutMultipleRenderersDEV, } from './ReactMutableSource.old'; import {logStateUpdateScheduled} from './DebugTracing'; -import {markStateUpdateScheduled} from './ReactFiberDevToolsHook.old'; +import { + markStateUpdateScheduled, + setIsStrictModeForDevtools, +} from './ReactFiberDevToolsHook.old'; import {createCache} from './ReactFiberCacheComponent.old'; import { createUpdate as createLegacyQueueUpdate, @@ -140,6 +145,7 @@ import { trackUsedThenable, checkIfUseWrappedInTryCatch, } from './ReactFiberThenable.old'; +import type {ThenableState} from './ReactFiberThenable.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -236,6 +242,7 @@ let didScheduleRenderPhaseUpdate: boolean = false; // TODO: Maybe there's some way to consolidate this with // `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`. let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; +let shouldDoubleInvokeUserFnsInHooksDEV: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; // Counts number of `use`-d thenables @@ -473,50 +480,69 @@ export function renderWithHooks( // If this is a replay, restore the thenable state from the previous attempt. const prevThenableState = getSuspendedThenableState(); prepareThenableState(prevThenableState); + + // In Strict Mode, during development, user functions are double invoked to + // help detect side effects. The logic for how this is implemented for in + // hook components is a bit complex so let's break it down. + // + // We will invoke the entire component function twice. However, during the + // second invocation of the component, the hook state from the first + // invocation will be reused. That means things like `useMemo` functions won't + // run again, because the deps will match and the memoized result will + // be reused. + // + // We want memoized functions to run twice, too, so account for this, user + // functions are double invoked during the *first* invocation of the component + // function, and are *not* double invoked during the second incovation: + // + // - First execution of component function: user functions are double invoked + // - Second execution of component function (in Strict Mode, during + // development): user functions are not double invoked. + // + // This is intentional for a few reasons; most importantly, it's because of + // how `use` works when something suspends: it reuses the promise that was + // passed during the first attempt. This is itself a form of memoization. + // We need to be able to memoize the reactive inputs to the `use` call using + // a hook (i.e. `useMemo`), which means, the reactive inputs to `use` must + // come from the same component invocation as the output. + // + // There are plenty of tests to ensure this behavior is correct. + const shouldDoubleRenderDEV = + __DEV__ && + debugRenderPhaseSideEffectsForStrictMode && + (workInProgress.mode & StrictLegacyMode) !== NoMode; + + shouldDoubleInvokeUserFnsInHooksDEV = shouldDoubleRenderDEV; let children = Component(props, secondArg); + shouldDoubleInvokeUserFnsInHooksDEV = false; // Check if there was a render phase update if (didScheduleRenderPhaseUpdateDuringThisPass) { - // Keep rendering in a loop for as long as render phase updates continue to - // be scheduled. Use a counter to prevent infinite loops. - let numberOfReRenders: number = 0; - do { - didScheduleRenderPhaseUpdateDuringThisPass = false; - localIdCounter = 0; - thenableIndexCounter = 0; - - if (numberOfReRenders >= RE_RENDER_LIMIT) { - throw new Error( - 'Too many re-renders. React limits the number of renders to prevent ' + - 'an infinite loop.', - ); - } - - numberOfReRenders += 1; - if (__DEV__) { - // Even when hot reloading, allow dependencies to stabilize - // after first render to prevent infinite render phase updates. - ignorePreviousDependencies = false; - } - - // Start over from the beginning of the list - currentHook = null; - workInProgressHook = null; - - workInProgress.updateQueue = null; - - if (__DEV__) { - // Also validate hook order for cascading updates. - hookTypesUpdateIndexDev = -1; - } - - ReactCurrentDispatcher.current = __DEV__ - ? HooksDispatcherOnRerenderInDEV - : HooksDispatcherOnRerender; + // Keep rendering until the component stabilizes (there are no more render + // phase updates). + children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + } - prepareThenableState(prevThenableState); - children = Component(props, secondArg); - } while (didScheduleRenderPhaseUpdateDuringThisPass); + if (shouldDoubleRenderDEV) { + // In development, components are invoked twice to help detect side effects. + setIsStrictModeForDevtools(true); + try { + children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + } finally { + setIsStrictModeForDevtools(false); + } } // We can assume the previous dispatcher is always this one, since we set it @@ -616,6 +642,65 @@ export function renderWithHooks( return children; } +function renderWithHooksAgain( + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, + prevThenableState: ThenableState | null, +) { + // This is used to perform another render pass. It's used when setState is + // called during render, and for double invoking components in Strict Mode + // during development. + // + // The state from the previous pass is reused whenever possible. So, state + // updates that were already processed are not processed again, and memoized + // functions (`useMemo`) are not invoked again. + // + // Keep rendering in a loop for as long as render phase updates continue to + // be scheduled. Use a counter to prevent infinite loops. + let numberOfReRenders: number = 0; + let children; + do { + didScheduleRenderPhaseUpdateDuringThisPass = false; + localIdCounter = 0; + thenableIndexCounter = 0; + + if (numberOfReRenders >= RE_RENDER_LIMIT) { + throw new Error( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + } + + numberOfReRenders += 1; + if (__DEV__) { + // Even when hot reloading, allow dependencies to stabilize + // after first render to prevent infinite render phase updates. + ignorePreviousDependencies = false; + } + + // Start over from the beginning of the list + currentHook = null; + workInProgressHook = null; + + workInProgress.updateQueue = null; + + if (__DEV__) { + // Also validate hook order for cascading updates. + hookTypesUpdateIndexDev = -1; + } + + ReactCurrentDispatcher.current = __DEV__ + ? HooksDispatcherOnRerenderInDEV + : HooksDispatcherOnRerender; + + prepareThenableState(prevThenableState); + children = Component(props, secondArg); + } while (didScheduleRenderPhaseUpdateDuringThisPass); + return children; +} + export function checkDidRenderIdHook(): boolean { // This should be called immediately after every renderWithHooks call. // Conceptually, it's part of the return value of renderWithHooks; it's only a @@ -1023,12 +1108,15 @@ function updateReducer( } // Process this update. + const action = update.action; + if (shouldDoubleInvokeUserFnsInHooksDEV) { + reducer(newState, action); + } if (update.hasEagerState) { // If this update is a state update (not a reducer) and was processed eagerly, // we can use the eagerly computed state newState = ((update.eagerState: any): S); } else { - const action = update.action; newState = reducer(newState, action); } } @@ -2110,6 +2198,9 @@ function mountMemo( ): T { const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; + if (shouldDoubleInvokeUserFnsInHooksDEV) { + nextCreate(); + } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; @@ -2131,6 +2222,9 @@ function updateMemo( } } } + if (shouldDoubleInvokeUserFnsInHooksDEV) { + nextCreate(); + } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; diff --git a/packages/react/src/__tests__/ReactStrictMode-test.js b/packages/react/src/__tests__/ReactStrictMode-test.js index 5f92f40946acd..a266af4575243 100644 --- a/packages/react/src/__tests__/ReactStrictMode-test.js +++ b/packages/react/src/__tests__/ReactStrictMode-test.js @@ -15,6 +15,10 @@ let ReactDOMClient; let ReactDOMServer; let Scheduler; let PropTypes; +let act; +let useMemo; +let useState; +let useReducer; const ReactFeatureFlags = require('shared/ReactFeatureFlags'); @@ -25,6 +29,10 @@ describe('ReactStrictMode', () => { ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server'); + act = require('jest-react').act; + useMemo = React.useMemo; + useState = React.useState; + useReducer = React.useReducer; }); it('should appear in the client component stack', () => { @@ -331,6 +339,183 @@ describe('ReactStrictMode', () => { // But each time `state` should be the previous value expect(instance.state.count).toBe(2); }); + + // @gate debugRenderPhaseSideEffectsForStrictMode + it('double invokes useMemo functions', async () => { + let log = []; + + function Uppercased({text}) { + return useMemo(() => { + const uppercased = text.toUpperCase(); + log.push('Compute toUpperCase: ' + uppercased); + return uppercased; + }, [text]); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + // Mount + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('HELLO'); + expect(log).toEqual([ + 'Compute toUpperCase: HELLO', + 'Compute toUpperCase: HELLO', + ]); + + log = []; + + // Update + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('GOODBYE'); + expect(log).toEqual([ + 'Compute toUpperCase: GOODBYE', + 'Compute toUpperCase: GOODBYE', + ]); + }); + + // @gate debugRenderPhaseSideEffectsForStrictMode + it('double invokes useMemo functions', async () => { + let log = []; + function Uppercased({text}) { + const memoizedResult = useMemo(() => { + const uppercased = text.toUpperCase(); + log.push('Compute toUpperCase: ' + uppercased); + return {uppercased}; + }, [text]); + + // Push this to the log so we can check whether the same memoized result + // it returned during both invocations. + log.push(memoizedResult); + + return memoizedResult.uppercased; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + // Mount + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('HELLO'); + expect(log).toEqual([ + 'Compute toUpperCase: HELLO', + 'Compute toUpperCase: HELLO', + {uppercased: 'HELLO'}, + {uppercased: 'HELLO'}, + ]); + + // Even though the memoized function is invoked twice, the same object + // is returned both times. + expect(log[2]).toBe(log[3]); + + log = []; + + // Update + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('GOODBYE'); + expect(log).toEqual([ + 'Compute toUpperCase: GOODBYE', + 'Compute toUpperCase: GOODBYE', + {uppercased: 'GOODBYE'}, + {uppercased: 'GOODBYE'}, + ]); + + // Even though the memoized function is invoked twice, the same object + // is returned both times. + expect(log[2]).toBe(log[3]); + }); + + // @gate debugRenderPhaseSideEffectsForStrictMode + it('double invokes setState updater functions', async () => { + const log = []; + + let setCount; + function App() { + const [count, _setCount] = useState(0); + setCount = _setCount; + return count; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('0'); + + await act(() => { + setCount(() => { + log.push('Compute count: 1'); + return 1; + }); + }); + expect(container.textContent).toBe('1'); + expect(log).toEqual(['Compute count: 1', 'Compute count: 1']); + }); + + // @gate debugRenderPhaseSideEffectsForStrictMode + it('double invokes reducer functions', async () => { + const log = []; + + function reducer(prevState, action) { + log.push('Compute new state: ' + action); + return action; + } + + let dispatch; + function App() { + const [count, _dispatch] = useReducer(reducer, 0); + dispatch = _dispatch; + return count; + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe('0'); + + await act(() => { + dispatch(1); + }); + expect(container.textContent).toBe('1'); + expect(log).toEqual(['Compute new state: 1', 'Compute new state: 1']); + }); }); describe('Concurrent Mode', () => {