diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 988ee30f5fc6c..048db562fe99b 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} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { getMaskedContext, @@ -125,6 +125,7 @@ import { createWorkInProgress, isSimpleFunctionComponent, } from './ReactFiber'; +import maxSigned31BitInt from './maxSigned31BitInt'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -237,19 +238,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 ((workInProgress.effectTag & PerformedWork) === NoEffect) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); } - nextChildren = finishHooks(render, nextProps, nextChildren, ref); - // React DevTools reads this flag. - workInProgress.effectTag |= PerformedWork; reconcileChildren( current, workInProgress, @@ -350,8 +369,6 @@ function updateMemoComponent( ); } } - // React DevTools reads this flag. - workInProgress.effectTag |= PerformedWork; let newChild = createWorkInProgress( currentChild, nextProps, @@ -506,19 +523,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 ((workInProgress.effectTag & PerformedWork) === NoEffect) { + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderExpirationTime, + ); } - nextChildren = finishHooks(Component, nextProps, nextChildren, context); - // React DevTools reads this flag. - workInProgress.effectTag |= PerformedWork; reconcileChildren( current, workInProgress, @@ -1063,7 +1098,6 @@ function mountIndeterminateComponent( const context = getMaskedContext(workInProgress, unmaskedContext); prepareToReadContext(workInProgress, renderExpirationTime); - prepareToUseHooks(null, workInProgress, renderExpirationTime); let value; @@ -1091,12 +1125,24 @@ 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; if ( typeof value === 'object' && @@ -1147,7 +1193,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); @@ -1539,17 +1584,17 @@ function updateContextProvider( } } - pushProvider(workInProgress, newValue); - + let changedBits; if (oldProps !== null) { const oldValue = oldProps.value; - const changedBits = calculateChangedBits(context, newValue, oldValue); + changedBits = calculateChangedBits(context, newValue, oldValue); if (changedBits === 0) { // No change. Bailout early if children are the same. if ( oldProps.children === newProps.children && !hasLegacyContextChanged() ) { + pushProvider(workInProgress, newValue, 0); return bailoutOnAlreadyFinishedWork( current, workInProgress, @@ -1566,7 +1611,10 @@ function updateContextProvider( renderExpirationTime, ); } + } else { + changedBits = maxSigned31BitInt; } + pushProvider(workInProgress, newValue, changedBits); const newChildren = newProps.children; reconcileChildren(current, workInProgress, newChildren, renderExpirationTime); @@ -1650,6 +1698,8 @@ function bailoutOnAlreadyFinishedWork( workInProgress.firstContextDependency = current.firstContextDependency; } + workInProgress.effectTag &= ~PerformedWork; + if (enableProfilerTimer) { // Don't update "base" render times for bailouts. stopProfilerTimerIfRunning(workInProgress); @@ -1680,11 +1730,12 @@ 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). + workInProgress.effectTag |= PerformedWork; + } else if (updateExpirationTime < renderExpirationTime) { // 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. @@ -1711,7 +1762,7 @@ function beginWork( break; case ContextProvider: { const newValue = workInProgress.memoizedProps.value; - pushProvider(workInProgress, newValue); + pushProvider(workInProgress, newValue, 0); break; } case Profiler: @@ -1767,6 +1818,9 @@ function beginWork( renderExpirationTime, ); } + } else { + // No bailouts on initial mount. + workInProgress.effectTag |= PerformedWork; } // 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 b859f4067b6a6..dacacd34be414 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -18,6 +18,8 @@ import {readContext} from './ReactFiberNewContext'; import { Update as UpdateEffect, Passive as PassiveEffect, + PerformedWork, + NoEffect, } from 'shared/ReactSideEffectTags'; import { NoEffect as NoHookEffect, @@ -36,23 +38,27 @@ import { import invariant from 'shared/invariant'; import areHookInputsEqual from 'shared/areHookInputsEqual'; -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 +110,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 +126,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 +151,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 +164,28 @@ export function finishHooks( componentUpdateQueue = null; children = Component(props, refOrContext); - } + } while (didScheduleRenderPhaseUpdate); + renderPhaseUpdates = null; - numberOfReRenders = 0; + numberOfReRenders = -1; const renderedWork: Fiber = (currentlyRenderingFiber: any); + if ( + current !== null && + (renderedWork.effectTag & PerformedWork) === NoEffect + ) { + // If nothing updated, clear the effects. We're going to bail out. + componentUpdateQueue = (current.updateQueue: any); + renderedWork.effectTag &= ~(PassiveEffect | UpdateEffect); + if (current.expirationTime <= renderExpirationTime) { + current.expirationTime = NoWork; + } + } + renderedWork.memoizedState = firstWorkInProgressHook; renderedWork.expirationTime = remainingExpirationTime; - renderedWork.updateQueue = (componentUpdateQueue: any); + renderedWork.updateQueue = componentUpdateQueue; const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; @@ -199,7 +207,7 @@ export function finishHooks( // These were reset above // didScheduleRenderPhaseUpdate = false; // renderPhaseUpdates = null; - // numberOfReRenders = 0; + // numberOfReRenders = -1; invariant( !didRenderTooFewHooks, @@ -215,9 +223,9 @@ export function resetHooks(): void { 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 +242,7 @@ export function resetHooks(): void { didScheduleRenderPhaseUpdate = false; renderPhaseUpdates = null; - numberOfReRenders = 0; + numberOfReRenders = -1; } function createHook(): Hook { @@ -347,7 +355,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 +398,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 +414,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 +437,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 +458,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) { + currentlyRenderingFiber.effectTag |= PerformedWork; + } + + queue.eagerReducer = reducer; + queue.eagerState = newState; } const dispatch: Dispatch = (queue.dispatch: any); @@ -462,7 +486,10 @@ export function useReducer( queue = workInProgressHook.queue = { last: null, dispatch: null, + eagerReducer: reducer, + eagerState: initialState, }; + currentlyRenderingFiber.effectTag |= PerformedWork; const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( null, currentlyRenderingFiber, @@ -632,7 +659,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 ' + @@ -648,9 +679,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) { @@ -668,14 +701,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) { @@ -690,6 +728,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..f4f2ba506228f 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -32,8 +32,11 @@ import { enqueueUpdate, ForceUpdate, } from 'react-reconciler/src/ReactUpdateQueue'; +import {PerformedWork} from 'shared/ReactSideEffectTags'; +import {NoEffect} from './ReactHookEffectTags'; const valueCursor: StackCursor = createCursor(null); +const changedBitsCursor: StackCursor = createCursor(0); let rendererSigil; if (__DEV__) { @@ -53,13 +56,19 @@ export function resetContextDependences(): void { lastContextWithAllBitsObserved = null; } -export function pushProvider(providerFiber: Fiber, nextValue: T): void { +export function pushProvider( + providerFiber: Fiber, + nextValue: T, + nextChangedBits: number, +): void { const context: ReactContext = providerFiber.type._context; if (isPrimaryRenderer) { push(valueCursor, context._currentValue, providerFiber); + push(changedBitsCursor, context._changedBits, providerFiber); context._currentValue = nextValue; + context._changedBits = nextChangedBits; if (__DEV__) { warningWithoutStack( context._currentRenderer === undefined || @@ -72,8 +81,10 @@ export function pushProvider(providerFiber: Fiber, nextValue: T): void { } } else { push(valueCursor, context._currentValue2, providerFiber); + push(changedBitsCursor, context._changedBits2, providerFiber); context._currentValue2 = nextValue; + context._changedBits2 = nextChangedBits; if (__DEV__) { warningWithoutStack( context._currentRenderer2 === undefined || @@ -89,14 +100,18 @@ export function pushProvider(providerFiber: Fiber, nextValue: T): void { export function popProvider(providerFiber: Fiber): void { const currentValue = valueCursor.current; + const currentChangedBits = changedBitsCursor.current; + pop(changedBitsCursor, providerFiber); pop(valueCursor, providerFiber); const context: ReactContext = providerFiber.type._context; if (isPrimaryRenderer) { context._currentValue = currentValue; + context._changedBits = currentChangedBits; } else { context._currentValue2 = currentValue; + context._changedBits2 = currentChangedBits; } } @@ -252,12 +267,18 @@ export function readContext( context: ReactContext, observedBits: void | number | boolean, ): T { + invariant( + currentlyRenderingFiber !== null, + 'Context can only be read while React is ' + + 'rendering, e.g. inside the render method or getDerivedStateFromProps.', + ); + + let resolvedObservedBits = 0; // Avoid deopting on observable arguments or heterogeneous types. if (lastContextWithAllBitsObserved === context) { // Nothing to do. We already observe everything in this context. } else if (observedBits === false || observedBits === 0) { // Do not observe any updates. } else { - let resolvedObservedBits; // Avoid deopting on observable arguments or heterogeneous types. if ( typeof observedBits !== 'number' || observedBits === MAX_SIGNED_31_BIT_INT @@ -276,11 +297,6 @@ export function readContext( }; if (lastContextDependency === null) { - invariant( - currentlyRenderingFiber !== null, - '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; } else { @@ -288,5 +304,16 @@ export function readContext( lastContextDependency = lastContextDependency.next = contextItem; } } + + if ((currentlyRenderingFiber.effectTag & PerformedWork) === NoEffect) { + const changedBits = isPrimaryRenderer + ? context._changedBits + : context._changedBits2; + if ((changedBits & resolvedObservedBits) !== 0) { + // Mark that this fiber performed work. + currentlyRenderingFiber.effectTag |= PerformedWork; + } + } + return isPrimaryRenderer ? context._currentValue : context._currentValue2; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 4dfc4e8bebd49..3e8a397030f75 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -1757,6 +1757,7 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { interactions.forEach(interaction => { if (!pendingInteractions.has(interaction)) { // Update the pending async work count for previously unscheduled interaction. + interaction.__count++; } diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 709d819d6713e..591ae8319315b 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -31,6 +31,324 @@ describe('ReactHooks', () => { ReactDOMServer = require('react-dom/server'); }); + 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('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/src/ReactContext.js b/packages/react/src/ReactContext.js index 643a13019e074..b998da077fe71 100644 --- a/packages/react/src/ReactContext.js +++ b/packages/react/src/ReactContext.js @@ -42,6 +42,8 @@ export function createContext( // Secondary renderers store their context values on separate fields. _currentValue: defaultValue, _currentValue2: defaultValue, + _changedBits: 0, + _changedBits2: 0, // Used to track how many concurrent renderers this context currently // supports within in a single renderer. Such as parallel server rendering. _threadCount: 0, @@ -101,6 +103,22 @@ export function createContext( context._currentValue2 = _currentValue2; }, }, + _changedBits: { + get() { + return context._changedBits; + }, + set(_changedBits) { + context._changedBits = _changedBits; + }, + }, + _changedBits2: { + get() { + return context._changedBits2; + }, + set(_changedBits2) { + context._changedBits2 = _changedBits2; + }, + }, _threadCount: { get() { return context._threadCount; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index f09207928ceaf..ef3edea791c30 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -59,6 +59,8 @@ export type ReactContext = { _currentValue: T, _currentValue2: T, + _changedBits: number, + _changedBits2: number, _threadCount: number, // DEV only