From 5450dd409863b31fa7ef4dfcf8aeb06ac16c4c10 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 28 Oct 2022 14:46:20 -0700 Subject: [PATCH] Strict Mode: Reuse memoized result from first pass (#25583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Strict Mode, during development, user functions are double invoked to help detect side effects. Currently, the way we implement this is to completely discard the first pass and start over. Theoretically this should be fine because components are idempotent. However, it's a bit tricky to get right because our implementation (i.e. `renderWithHooks`) is not completely idempotent with respect to internal data structures, like the work-in-progress fiber. In the past we've had to be really careful to avoid subtle bugs — for example, during the initial mount, `setState` functions are bound to the particular hook instances that were created during that render. If we compute new hook instances, we must also compute new children, and they must correspond to each other. This commit addresses a similar issue that came up related to `use`: when something suspends, `use` 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. The solution I've chosen is, rather than double invoke the entire `renderWithHook` function, we should double invoke each individual user function. It's a bit confusing but here's how it works: 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. It's hard to explain verbally but much clearer when you run the test cases I've added. --- .../src/ReactFiberBeginWork.new.js | 58 ------ .../src/ReactFiberBeginWork.old.js | 58 ------ .../src/ReactFiberHooks.new.js | 176 +++++++++++++---- .../src/ReactFiberHooks.old.js | 176 +++++++++++++---- .../src/__tests__/ReactStrictMode-test.js | 185 ++++++++++++++++++ 5 files changed, 455 insertions(+), 198 deletions(-) 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', () => {