From 790c8ef04195f0fc11ca3fb08e63f870f81483ac Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 16 Jan 2019 17:23:35 -0800 Subject: [PATCH] Allow useReducer to bail out of rendering by returning previous state (#14569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow useReducer to bail out of rendering by returning previous state This is conceptually similar to `shouldComponentUpdate`, except because there could be multiple useReducer (or useState) Hooks in a single component, we can only bail out if none of the Hooks produce a new value. We also can't bail out if any the other types of inputs — state and context — have changed. These optimizations rely on the constraint that components are pure functions of props, state, and context. In some cases, we can bail out without entering the render phase by eagerly computing the next state and comparing it to the current one. This only works if we are absolutely certain that the queue is empty at the time of the update. In concurrent mode, this is difficult to determine, because there could be multiple copies of the queue and we don't know which one is current without doing lots of extra work, which would defeat the purpose of the optimization. However, in our implementation, there are at most only two copies of the queue, and if *both* are empty then we know that the current queue must be. * Add test for context consumers inside hidden subtree Should not bail out during subsequent update. (This isn't directly related to this PR because we should have had this test, anyway.) * Refactor to use module-level variable instead of effect bit * Add test combining state bailout and props bailout (memo) --- packages/react-reconciler/src/ReactFiber.js | 10 +- .../src/ReactFiberBeginWork.js | 119 ++++-- .../src/ReactFiberCompleteWork.js | 17 +- .../react-reconciler/src/ReactFiberHooks.js | 163 +++++--- .../src/ReactFiberNewContext.js | 50 ++- .../src/__tests__/ReactHooks-test.internal.js | 384 ++++++++++++++++++ .../ReactNewContext-test.internal.js | 31 ++ 7 files changed, 673 insertions(+), 101 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 8ad290182eb2d..681e3e419e135 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -14,7 +14,7 @@ import type {TypeOfMode} from './ReactTypeOfMode'; import type {SideEffectTag} from 'shared/ReactSideEffectTags'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {UpdateQueue} from './ReactUpdateQueue'; -import type {ContextDependency} from './ReactFiberNewContext'; +import type {ContextDependencyList} from './ReactFiberNewContext'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; @@ -141,7 +141,7 @@ export type Fiber = {| memoizedState: any, // A linked-list of contexts that this fiber depends on - firstContextDependency: ContextDependency | null, + contextDependencies: ContextDependencyList | null, // Bitfield that describes properties about the fiber and its subtree. E.g. // the ConcurrentMode flag indicates whether the subtree should be async-by- @@ -237,7 +237,7 @@ function FiberNode( this.memoizedProps = null; this.updateQueue = null; this.memoizedState = null; - this.firstContextDependency = null; + this.contextDependencies = null; this.mode = mode; @@ -403,7 +403,7 @@ export function createWorkInProgress( workInProgress.memoizedProps = current.memoizedProps; workInProgress.memoizedState = current.memoizedState; workInProgress.updateQueue = current.updateQueue; - workInProgress.firstContextDependency = current.firstContextDependency; + workInProgress.contextDependencies = current.contextDependencies; // These will be overridden during the parent's reconciliation workInProgress.sibling = current.sibling; @@ -704,7 +704,7 @@ export function assignFiberPropertiesInDEV( target.memoizedProps = source.memoizedProps; target.updateQueue = source.updateQueue; target.memoizedState = source.memoizedState; - target.firstContextDependency = source.firstContextDependency; + target.contextDependencies = source.contextDependencies; target.mode = source.mode; target.effectTag = source.effectTag; target.nextEffect = source.nextEffect; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 988ee30f5fc6c..a00b795b16dd4 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -90,7 +90,7 @@ import { prepareToReadContext, calculateChangedBits, } from './ReactFiberNewContext'; -import {prepareToUseHooks, finishHooks, resetHooks} from './ReactFiberHooks'; +import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { getMaskedContext, @@ -128,6 +128,8 @@ import { const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; +let didReceiveUpdate: boolean = false; + let didWarnAboutBadClass; let didWarnAboutContextTypeOnFunctionComponent; let didWarnAboutGetDerivedStateOnFunctionComponent; @@ -237,16 +239,37 @@ function updateForwardRef( // The rest is a fork of updateFunctionComponent let nextChildren; prepareToReadContext(workInProgress, renderExpirationTime); - prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; setCurrentPhase('render'); - nextChildren = render(nextProps, ref); + nextChildren = renderWithHooks( + current, + workInProgress, + render, + nextProps, + ref, + renderExpirationTime, + ); setCurrentPhase(null); } else { - nextChildren = render(nextProps, ref); + nextChildren = renderWithHooks( + current, + workInProgress, + render, + nextProps, + ref, + renderExpirationTime, + ); + } + + if (current !== null && !didReceiveUpdate) { + bailoutHooks(current, workInProgress, renderExpirationTime); + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); } - nextChildren = finishHooks(render, nextProps, nextChildren, ref); // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; @@ -395,17 +418,20 @@ function updateSimpleMemoComponent( // Inner propTypes will be validated in the function component path. } } - if (current !== null && updateExpirationTime < renderExpirationTime) { + if (current !== null) { const prevProps = current.memoizedProps; if ( shallowEqual(prevProps, nextProps) && current.ref === workInProgress.ref ) { - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderExpirationTime, - ); + didReceiveUpdate = false; + if (updateExpirationTime < renderExpirationTime) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); + } } } return updateFunctionComponent( @@ -506,16 +532,37 @@ function updateFunctionComponent( let nextChildren; prepareToReadContext(workInProgress, renderExpirationTime); - prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; setCurrentPhase('render'); - nextChildren = Component(nextProps, context); + nextChildren = renderWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + renderExpirationTime, + ); setCurrentPhase(null); } else { - nextChildren = Component(nextProps, context); + nextChildren = renderWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + renderExpirationTime, + ); + } + + if (current !== null && !didReceiveUpdate) { + bailoutHooks(current, workInProgress, renderExpirationTime); + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); } - nextChildren = finishHooks(Component, nextProps, nextChildren, context); // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; @@ -850,7 +897,7 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) { shouldDeprioritizeSubtree(type, nextProps) ) { // Schedule this fiber to re-render at offscreen priority. Then bailout. - workInProgress.expirationTime = Never; + workInProgress.expirationTime = workInProgress.childExpirationTime = Never; return null; } @@ -1063,7 +1110,6 @@ function mountIndeterminateComponent( const context = getMaskedContext(workInProgress, unmaskedContext); prepareToReadContext(workInProgress, renderExpirationTime); - prepareToUseHooks(null, workInProgress, renderExpirationTime); let value; @@ -1091,9 +1137,23 @@ function mountIndeterminateComponent( } ReactCurrentOwner.current = workInProgress; - value = Component(props, context); + value = renderWithHooks( + null, + workInProgress, + Component, + props, + context, + renderExpirationTime, + ); } else { - value = Component(props, context); + value = renderWithHooks( + null, + workInProgress, + Component, + props, + context, + renderExpirationTime, + ); } // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; @@ -1147,7 +1207,6 @@ function mountIndeterminateComponent( } else { // Proceed under the assumption that this is a function component workInProgress.tag = FunctionComponent; - value = finishHooks(Component, props, value, context); reconcileChildren(null, workInProgress, value, renderExpirationTime); if (__DEV__) { validateFunctionComponentInDev(workInProgress, Component); @@ -1638,6 +1697,10 @@ function updateContextConsumer( return workInProgress.child; } +export function markWorkInProgressReceivedUpdate() { + didReceiveUpdate = true; +} + function bailoutOnAlreadyFinishedWork( current: Fiber | null, workInProgress: Fiber, @@ -1647,7 +1710,7 @@ function bailoutOnAlreadyFinishedWork( if (current !== null) { // Reuse previous context list - workInProgress.firstContextDependency = current.firstContextDependency; + workInProgress.contextDependencies = current.contextDependencies; } if (enableProfilerTimer) { @@ -1680,11 +1743,13 @@ function beginWork( if (current !== null) { const oldProps = current.memoizedProps; const newProps = workInProgress.pendingProps; - if ( - oldProps === newProps && - !hasLegacyContextChanged() && - updateExpirationTime < renderExpirationTime - ) { + + if (oldProps !== newProps || hasLegacyContextChanged()) { + // If props or context changed, mark the fiber as having performed work. + // This may be unset if the props are determined to be equal later (memo). + didReceiveUpdate = true; + } else if (updateExpirationTime < renderExpirationTime) { + didReceiveUpdate = false; // This fiber does not have any pending work. Bailout without entering // the begin phase. There's still some bookkeeping we that needs to be done // in this optimized path, mostly pushing stuff onto the stack. @@ -1767,6 +1832,8 @@ function beginWork( renderExpirationTime, ); } + } else { + didReceiveUpdate = false; } // Before entering the begin phase, clear the expiration time. diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index fbd3b5b65fffc..01bca07a3ce3e 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -82,7 +82,6 @@ import { prepareToHydrateHostTextInstance, popHydrationState, } from './ReactFiberHydrationContext'; -import {ConcurrentMode, NoContext} from './ReactTypeOfMode'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -728,18 +727,10 @@ function completeWork( } } - // The children either timed out after previously being visible, or - // were restored after previously being hidden. Schedule an effect - // to update their visiblity. - if ( - // - nextDidTimeout !== prevDidTimeout || - // Outside concurrent mode, the primary children commit in an - // inconsistent state, even if they are hidden. So if they are hidden, - // we need to schedule an effect to re-hide them, just in case. - ((workInProgress.effectTag & ConcurrentMode) === NoContext && - nextDidTimeout) - ) { + if (nextDidTimeout || prevDidTimeout) { + // If the children are hidden, or if they were previous hidden, schedule + // an effect to toggle their visibility. This is also used to attach a + // retry listener to the promise. workInProgress.effectTag |= Update; } break; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index fa572a882ff46..a764a6c1d6651 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -35,24 +35,29 @@ import { import invariant from 'shared/invariant'; import areHookInputsEqual from 'shared/areHookInputsEqual'; +import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; -type Update = { +type Update = { expirationTime: ExpirationTime, action: A, - next: Update | null, + eagerReducer: ((S, A) => S) | null, + eagerState: S | null, + next: Update | null, }; -type UpdateQueue = { - last: Update | null, - dispatch: any, +type UpdateQueue = { + last: Update | null, + dispatch: (A => mixed) | null, + eagerReducer: ((S, A) => S) | null, + eagerState: S | null, }; export type Hook = { memoizedState: any, baseState: any, - baseUpdate: Update | null, - queue: UpdateQueue | null, + baseUpdate: Update | null, + queue: UpdateQueue | null, next: Hook | null, }; @@ -104,9 +109,12 @@ let isReRender: boolean = false; // Whether an update was scheduled during the currently executing render pass. let didScheduleRenderPhaseUpdate: boolean = false; // Lazily created map of render-phase updates -let renderPhaseUpdates: Map, Update> | null = null; +let renderPhaseUpdates: Map< + UpdateQueue, + Update, +> | null = null; // Counter to prevent infinite loops. -let numberOfReRenders: number = 0; +let numberOfReRenders: number = -1; const RE_RENDER_LIMIT = 25; function resolveCurrentlyRenderingFiber(): Fiber { @@ -117,13 +125,16 @@ function resolveCurrentlyRenderingFiber(): Fiber { return currentlyRenderingFiber; } -export function prepareToUseHooks( +export function renderWithHooks( current: Fiber | null, workInProgress: Fiber, + Component: any, + props: any, + refOrContext: any, nextRenderExpirationTime: ExpirationTime, -): void { +): any { if (!enableHooks) { - return; + return Component(props, refOrContext); } renderExpirationTime = nextRenderExpirationTime; currentlyRenderingFiber = workInProgress; @@ -139,27 +150,10 @@ export function prepareToUseHooks( // isReRender = false; // didScheduleRenderPhaseUpdate = false; // renderPhaseUpdates = null; - // numberOfReRenders = 0; -} - -export function finishHooks( - Component: any, - props: any, - children: any, - refOrContext: any, -): any { - if (!enableHooks) { - return children; - } - - // This must be called after every function component to prevent hooks from - // being used in classes. + // numberOfReRenders = -1; - while (didScheduleRenderPhaseUpdate) { - // Updates were scheduled during the render phase. They are stored in - // the `renderPhaseUpdates` map. Call the component again, reusing the - // work-in-progress hooks and applying the additional updates on top. Keep - // restarting until no more updates are scheduled. + let children; + do { didScheduleRenderPhaseUpdate = false; numberOfReRenders += 1; @@ -169,15 +163,16 @@ export function finishHooks( componentUpdateQueue = null; children = Component(props, refOrContext); - } + } while (didScheduleRenderPhaseUpdate); + renderPhaseUpdates = null; - numberOfReRenders = 0; + numberOfReRenders = -1; const renderedWork: Fiber = (currentlyRenderingFiber: any); renderedWork.memoizedState = firstWorkInProgressHook; renderedWork.expirationTime = remainingExpirationTime; - renderedWork.updateQueue = (componentUpdateQueue: any); + renderedWork.updateQueue = componentUpdateQueue; const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; @@ -199,7 +194,7 @@ export function finishHooks( // These were reset above // didScheduleRenderPhaseUpdate = false; // renderPhaseUpdates = null; - // numberOfReRenders = 0; + // numberOfReRenders = -1; invariant( !didRenderTooFewHooks, @@ -210,14 +205,26 @@ export function finishHooks( return children; } +export function bailoutHooks( + current: Fiber, + workInProgress: Fiber, + expirationTime: ExpirationTime, +) { + workInProgress.updateQueue = current.updateQueue; + workInProgress.effectTag &= ~(PassiveEffect | UpdateEffect); + if (current.expirationTime <= expirationTime) { + current.expirationTime = NoWork; + } +} + export function resetHooks(): void { if (!enableHooks) { return; } - // This is called instead of `finishHooks` if the component throws. It's also - // called inside mountIndeterminateComponent if we determine the component - // is a module-style component. + // This is used to reset the state of this module when a component throws. + // It's also called inside mountIndeterminateComponent if we determine the + // component is a module-style component. renderExpirationTime = NoWork; currentlyRenderingFiber = null; @@ -234,7 +241,7 @@ export function resetHooks(): void { didScheduleRenderPhaseUpdate = false; renderPhaseUpdates = null; - numberOfReRenders = 0; + numberOfReRenders = -1; } function createHook(): Hook { @@ -347,7 +354,7 @@ export function useReducer( ): [S, Dispatch] { currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); workInProgressHook = createWorkInProgressHook(); - let queue: UpdateQueue | null = (workInProgressHook.queue: any); + let queue: UpdateQueue | null = (workInProgressHook.queue: any); if (queue !== null) { // Already have a queue, so this is an update. if (isReRender) { @@ -390,6 +397,7 @@ export function useReducer( const last = queue.last; // The last update that is part of the base state. const baseUpdate = workInProgressHook.baseUpdate; + const baseState = workInProgressHook.baseState; // Find the first unprocessed update. let first; @@ -405,7 +413,7 @@ export function useReducer( first = last !== null ? last.next : null; } if (first !== null) { - let newState = workInProgressHook.baseState; + let newState = baseState; let newBaseState = null; let newBaseUpdate = null; let prevUpdate = baseUpdate; @@ -428,8 +436,14 @@ export function useReducer( } } else { // Process this update. - const action = update.action; - newState = reducer(newState, action); + if (update.eagerReducer === reducer) { + // If this update was processed eagerly, and its reducer matches the + // current reducer, we can use the eagerly computed state. + newState = ((update.eagerState: any): S); + } else { + const action = update.action; + newState = reducer(newState, action); + } } prevUpdate = update; update = update.next; @@ -443,6 +457,15 @@ export function useReducer( workInProgressHook.memoizedState = newState; workInProgressHook.baseUpdate = newBaseUpdate; workInProgressHook.baseState = newBaseState; + + // Mark that the fiber performed work, but only if the new state is + // different from the current state. + if (newState !== (currentHook: any).memoizedState) { + markWorkInProgressReceivedUpdate(); + } + + queue.eagerReducer = reducer; + queue.eagerState = newState; } const dispatch: Dispatch = (queue.dispatch: any); @@ -462,6 +485,8 @@ export function useReducer( queue = workInProgressHook.queue = { last: null, dispatch: null, + eagerReducer: reducer, + eagerState: initialState, }; const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( null, @@ -644,7 +669,11 @@ export function useMemo( return nextValue; } -function dispatchAction(fiber: Fiber, queue: UpdateQueue, action: A) { +function dispatchAction( + fiber: Fiber, + queue: UpdateQueue, + action: A, +) { invariant( numberOfReRenders < RE_RENDER_LIMIT, 'Too many re-renders. React limits the number of renders to prevent ' + @@ -660,9 +689,11 @@ function dispatchAction(fiber: Fiber, queue: UpdateQueue, action: A) { // queue -> linked list of updates. After this render pass, we'll restart // and apply the stashed updates on top of the work-in-progress hook. didScheduleRenderPhaseUpdate = true; - const update: Update = { + const update: Update = { expirationTime: renderExpirationTime, action, + eagerReducer: null, + eagerState: null, next: null, }; if (renderPhaseUpdates === null) { @@ -680,14 +711,19 @@ function dispatchAction(fiber: Fiber, queue: UpdateQueue, action: A) { lastRenderPhaseUpdate.next = update; } } else { + flushPassiveEffects(); + const currentTime = requestCurrentTime(); const expirationTime = computeExpirationForFiber(currentTime, fiber); - const update: Update = { + + const update: Update = { expirationTime, action, + eagerReducer: null, + eagerState: null, next: null, }; - flushPassiveEffects(); + // Append the update to the end of the list. const last = queue.last; if (last === null) { @@ -702,6 +738,37 @@ function dispatchAction(fiber: Fiber, queue: UpdateQueue, action: A) { last.next = update; } queue.last = update; + + if ( + fiber.expirationTime === NoWork && + (alternate === null || alternate.expirationTime === NoWork) + ) { + // The queue is currently empty, which means we can eagerly compute the + // next state before entering the render phase. If the new state is the + // same as the current state, we may be able to bail out entirely. + const eagerReducer = queue.eagerReducer; + if (eagerReducer !== null) { + try { + const currentState: S = (queue.eagerState: any); + const eagerState = eagerReducer(currentState, action); + // Stash the eagerly computed state, and the reducer used to compute + // it, on the update object. If the reducer hasn't changed by the + // time we enter the render phase, then the eager state can be used + // without calling the reducer again. + update.eagerReducer = eagerReducer; + update.eagerState = eagerState; + if (eagerState === currentState) { + // Fast path. We can bail out without scheduling React to re-render. + // It's still possible that we'll need to rebase this update later, + // if the component re-renders for a different reason and by that + // time the reducer has changed. + return; + } + } catch (error) { + // Suppress the error. It will throw again in the render phase. + } + } + } scheduleWork(fiber, expirationTime); } } diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index 23439ff706bcf..9687f40d7f12b 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -12,7 +12,12 @@ import type {Fiber} from './ReactFiber'; import type {StackCursor} from './ReactFiberStack'; import type {ExpirationTime} from './ReactFiberExpirationTime'; -export type ContextDependency = { +export type ContextDependencyList = { + first: ContextDependency, + expirationTime: ExpirationTime, +}; + +type ContextDependency = { context: ReactContext, observedBits: number, next: ContextDependency | null, @@ -32,6 +37,8 @@ import { enqueueUpdate, ForceUpdate, } from 'react-reconciler/src/ReactUpdateQueue'; +import {NoWork} from './ReactFiberExpirationTime'; +import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; const valueCursor: StackCursor = createCursor(null); @@ -141,9 +148,12 @@ export function propagateContextChange( let nextFiber; // Visit this fiber. - let dependency = fiber.firstContextDependency; - if (dependency !== null) { - do { + const list = fiber.contextDependencies; + if (list !== null) { + nextFiber = fiber.child; + + let dependency = list.first; + while (dependency !== null) { // Check if the context matches. if ( dependency.context === context && @@ -197,10 +207,18 @@ export function propagateContextChange( } node = node.return; } + + // Mark the expiration time on the list, too. + if (list.expirationTime < renderExpirationTime) { + list.expirationTime = renderExpirationTime; + } + + // Since we already found a match, we can stop traversing the + // dependency list. + break; } - nextFiber = fiber.child; dependency = dependency.next; - } while (dependency !== null); + } } else if (fiber.tag === ContextProvider) { // Don't scan deeper if this is a matching provider nextFiber = fiber.type === workInProgress.type ? null : fiber.child; @@ -244,8 +262,17 @@ export function prepareToReadContext( lastContextDependency = null; lastContextWithAllBitsObserved = null; + const currentDependencies = workInProgress.contextDependencies; + if ( + currentDependencies !== null && + currentDependencies.expirationTime >= renderExpirationTime + ) { + // Context list has a pending update. Mark that this fiber performed work. + markWorkInProgressReceivedUpdate(); + } + // Reset the work-in-progress list - workInProgress.firstContextDependency = null; + workInProgress.contextDependencies = null; } export function readContext( @@ -281,8 +308,13 @@ export function readContext( 'Context can only be read while React is ' + 'rendering, e.g. inside the render method or getDerivedStateFromProps.', ); - // This is the first dependency in the list - currentlyRenderingFiber.firstContextDependency = lastContextDependency = contextItem; + + // This is the first dependency for this component. Create a new list. + lastContextDependency = contextItem; + currentlyRenderingFiber.contextDependencies = { + first: contextItem, + expirationTime: NoWork, + }; } else { // Append a new context item. lastContextDependency = lastContextDependency.next = contextItem; diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 7b442ebf77eda..7014bfe343094 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -48,6 +48,390 @@ describe('ReactHooks', () => { }); } + it('bails out in the render phase if all of the state is the same', () => { + const {useState, useLayoutEffect} = React; + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter1; + let setCounter2; + function Parent() { + const [counter1, _setCounter1] = useState(0); + setCounter1 = _setCounter1; + const [counter2, _setCounter2] = useState(0); + setCounter2 = _setCounter2; + + const text = `${counter1}, ${counter2}`; + ReactTestRenderer.unstable_yield(`Parent: ${text}`); + useLayoutEffect(() => { + ReactTestRenderer.unstable_yield(`Effect: ${text}`); + }); + return ; + } + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update(); + expect(root).toFlushAndYield([ + 'Parent: 0, 0', + 'Child: 0, 0', + 'Effect: 0, 0', + ]); + expect(root).toMatchRenderedOutput('0, 0'); + + // Normal update + setCounter1(1); + setCounter2(1); + expect(root).toFlushAndYield([ + 'Parent: 1, 1', + 'Child: 1, 1', + 'Effect: 1, 1', + ]); + + // Update that bails out. + setCounter1(1); + expect(root).toFlushAndYield(['Parent: 1, 1']); + + // This time, one of the state updates but the other one doesn't. So we + // can't bail out. + setCounter1(1); + setCounter2(2); + expect(root).toFlushAndYield([ + 'Parent: 1, 2', + 'Child: 1, 2', + 'Effect: 1, 2', + ]); + + // Lots of updates that eventually resolve to the current values. + setCounter1(9); + setCounter2(3); + setCounter1(4); + setCounter2(7); + setCounter1(1); + setCounter2(2); + + // Because the final values are the same as the current values, the + // component bails out. + expect(root).toFlushAndYield(['Parent: 1, 2']); + }); + + it('bails out in render phase if all the state is the same and props bail out with memo', () => { + const {useState, memo} = React; + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter1; + let setCounter2; + function Parent({theme}) { + const [counter1, _setCounter1] = useState(0); + setCounter1 = _setCounter1; + const [counter2, _setCounter2] = useState(0); + setCounter2 = _setCounter2; + + const text = `${counter1}, ${counter2} (${theme})`; + ReactTestRenderer.unstable_yield(`Parent: ${text}`); + return ; + } + + Parent = memo(Parent); + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update(); + expect(root).toFlushAndYield([ + 'Parent: 0, 0 (light)', + 'Child: 0, 0 (light)', + ]); + expect(root).toMatchRenderedOutput('0, 0 (light)'); + + // Normal update + setCounter1(1); + setCounter2(1); + expect(root).toFlushAndYield([ + 'Parent: 1, 1 (light)', + 'Child: 1, 1 (light)', + ]); + + // Update that bails out. + setCounter1(1); + expect(root).toFlushAndYield(['Parent: 1, 1 (light)']); + + // This time, one of the state updates but the other one doesn't. So we + // can't bail out. + setCounter1(1); + setCounter2(2); + expect(root).toFlushAndYield([ + 'Parent: 1, 2 (light)', + 'Child: 1, 2 (light)', + ]); + + // Updates bail out, but component still renders because props + // have changed + setCounter1(1); + setCounter2(2); + root.update(); + expect(root).toFlushAndYield(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']); + + // Both props and state bail out + setCounter1(1); + setCounter2(2); + root.update(); + expect(root).toFlushAndYield(['Parent: 1, 2 (dark)']); + }); + + it('never bails out if context has changed', () => { + const {useState, useLayoutEffect, useContext} = React; + + const ThemeContext = React.createContext('light'); + + let setTheme; + function ThemeProvider({children}) { + const [theme, _setTheme] = useState('light'); + ReactTestRenderer.unstable_yield('Theme: ' + theme); + setTheme = _setTheme; + return ( + {children} + ); + } + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter; + function Parent() { + const [counter, _setCounter] = useState(0); + setCounter = _setCounter; + + const theme = useContext(ThemeContext); + + const text = `${counter} (${theme})`; + ReactTestRenderer.unstable_yield(`Parent: ${text}`); + useLayoutEffect(() => { + ReactTestRenderer.unstable_yield(`Effect: ${text}`); + }); + return ; + } + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update( + + + , + ); + expect(root).toFlushAndYield([ + 'Theme: light', + 'Parent: 0 (light)', + 'Child: 0 (light)', + 'Effect: 0 (light)', + ]); + expect(root).toMatchRenderedOutput('0 (light)'); + + // Updating the theme to the same value does't cause the consumers + // to re-render. + setTheme('light'); + expect(root).toFlushAndYield([]); + expect(root).toMatchRenderedOutput('0 (light)'); + + // Normal update + setCounter(1); + expect(root).toFlushAndYield([ + 'Parent: 1 (light)', + 'Child: 1 (light)', + 'Effect: 1 (light)', + ]); + expect(root).toMatchRenderedOutput('1 (light)'); + + // Update that doesn't change state, so it bails out + setCounter(1); + expect(root).toFlushAndYield(['Parent: 1 (light)']); + expect(root).toMatchRenderedOutput('1 (light)'); + + // Update that doesn't change state, but the context changes, too, so it + // can't bail out + setCounter(1); + setTheme('dark'); + expect(root).toFlushAndYield([ + 'Theme: dark', + 'Parent: 1 (dark)', + 'Child: 1 (dark)', + 'Effect: 1 (dark)', + ]); + expect(root).toMatchRenderedOutput('1 (dark)'); + }); + + it('can bail out without calling render phase (as an optimization) if queue is known to be empty', () => { + const {useState, useLayoutEffect} = React; + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter; + function Parent() { + const [counter, _setCounter] = useState(0); + setCounter = _setCounter; + ReactTestRenderer.unstable_yield('Parent: ' + counter); + useLayoutEffect(() => { + ReactTestRenderer.unstable_yield('Effect: ' + counter); + }); + return ; + } + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update(); + expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']); + expect(root).toMatchRenderedOutput('0'); + + // Normal update + setCounter(1); + expect(root).toFlushAndYield(['Parent: 1', 'Child: 1', 'Effect: 1']); + expect(root).toMatchRenderedOutput('1'); + + // Update to the same state. React doesn't know if the queue is empty + // because the alterate fiber has pending update priority, so we have to + // enter the render phase before we can bail out. But we bail out before + // rendering the child, and we don't fire any effects. + setCounter(1); + expect(root).toFlushAndYield(['Parent: 1']); + expect(root).toMatchRenderedOutput('1'); + + // Update to the same state again. This times, neither fiber has pending + // update priority, so we can bail out before even entering the render phase. + setCounter(1); + expect(root).toFlushAndYield([]); + expect(root).toMatchRenderedOutput('1'); + + // This changes the state to something different so it renders normally. + setCounter(2); + expect(root).toFlushAndYield(['Parent: 2', 'Child: 2', 'Effect: 2']); + expect(root).toMatchRenderedOutput('2'); + }); + + it('bails out multiple times in a row without entering render phase', () => { + const {useState} = React; + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter; + function Parent() { + const [counter, _setCounter] = useState(0); + setCounter = _setCounter; + ReactTestRenderer.unstable_yield('Parent: ' + counter); + return ; + } + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update(); + expect(root).toFlushAndYield(['Parent: 0', 'Child: 0']); + expect(root).toMatchRenderedOutput('0'); + + const update = value => { + setCounter(previous => { + ReactTestRenderer.unstable_yield( + `Compute state (${previous} -> ${value})`, + ); + return value; + }); + }; + update(0); + update(0); + update(0); + update(1); + update(2); + update(3); + + expect(ReactTestRenderer).toHaveYielded([ + // The first four updates were eagerly computed, because the queue is + // empty before each one. + 'Compute state (0 -> 0)', + 'Compute state (0 -> 0)', + 'Compute state (0 -> 0)', + // The fourth update doesn't bail out + 'Compute state (0 -> 1)', + // so subsequent updates can't be eagerly computed. + ]); + + // Now let's enter the render phase + expect(root).toFlushAndYield([ + // We don't need to re-compute the first four updates. Only the final two. + 'Compute state (1 -> 2)', + 'Compute state (2 -> 3)', + 'Parent: 3', + 'Child: 3', + ]); + expect(root).toMatchRenderedOutput('3'); + }); + + it('can rebase on top of a previously skipped update', () => { + const {useState} = React; + + function Child({text}) { + ReactTestRenderer.unstable_yield('Child: ' + text); + return text; + } + + let setCounter; + function Parent() { + const [counter, _setCounter] = useState(1); + setCounter = _setCounter; + ReactTestRenderer.unstable_yield('Parent: ' + counter); + return ; + } + + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true}); + root.update(); + expect(root).toFlushAndYield(['Parent: 1', 'Child: 1']); + expect(root).toMatchRenderedOutput('1'); + + const update = compute => { + setCounter(previous => { + const value = compute(previous); + ReactTestRenderer.unstable_yield( + `Compute state (${previous} -> ${value})`, + ); + return value; + }); + }; + + // Update at normal priority + update(n => n * 100); + + // The new state is eagerly computed. + expect(ReactTestRenderer).toHaveYielded(['Compute state (1 -> 100)']); + + // but before it's flushed, a higher priority update interrupts it. + root.unstable_flushSync(() => { + update(n => n + 5); + }); + expect(ReactTestRenderer).toHaveYielded([ + // The eagerly computed state was completely skipped + 'Compute state (1 -> 6)', + 'Parent: 6', + 'Child: 6', + ]); + expect(root).toMatchRenderedOutput('6'); + + // Now when we finish the first update, the second update is rebased on top. + // Notice we didn't have to recompute the first update even though it was + // skipped in the previous render. + expect(root).toFlushAndYield([ + 'Compute state (100 -> 105)', + 'Parent: 105', + 'Child: 105', + ]); + expect(root).toMatchRenderedOutput('105'); + }); + it('warns about variable number of dependencies', () => { const {useLayoutEffect} = React; function App(props) { diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 9f3d2d6c4d2a9..3b9873923a173 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -883,6 +883,37 @@ describe('ReactNewContext', () => { expect(ReactNoop.getChildren()).toEqual([span(2), span(2)]); }); + it("context consumer doesn't bail out inside hidden subtree", () => { + const Context = React.createContext('dark'); + const Consumer = getConsumer(Context); + + function App({theme}) { + return ( + + + + ); + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['dark']); + expect(ReactNoop.getChildrenAsJSX()).toEqual( + , + ); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['light']); + expect(ReactNoop.getChildrenAsJSX()).toEqual( + , + ); + }); + // This is a regression case for https://github.com/facebook/react/issues/12389. it('does not run into an infinite loop', () => { const Context = React.createContext(null);