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);