@samitouri / QOS-React / commits / 790c8ef041

Allow useReducer to bail out of rendering by returning previous state (#14569)

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

Andrew Clark committed Jan 16, 2019 at 17:23 UTC 790c8ef04195f0fc11ca3fb08e63f870f81483ac
7 files changed +673 -101
packages/react-reconciler/src/ReactFiber.js
+5 -5
@@ -14,7 +14,7 @@ import type {TypeOfMode} from './ReactTypeOfMode';
14 import type {SideEffectTag} from 'shared/ReactSideEffectTags';
15 import type {ExpirationTime} from './ReactFiberExpirationTime';
16 import type {UpdateQueue} from './ReactUpdateQueue';
17 -import type {ContextDependency} from './ReactFiberNewContext';
17 +import type {ContextDependencyList} from './ReactFiberNewContext';
18
19 import invariant from 'shared/invariant';
20 import warningWithoutStack from 'shared/warningWithoutStack';
@@ -141,7 +141,7 @@ export type Fiber = {|
141 memoizedState: any,
142
143 // A linked-list of contexts that this fiber depends on
144 - firstContextDependency: ContextDependency<mixed> | null,
144 + contextDependencies: ContextDependencyList | null,
145
146 // Bitfield that describes properties about the fiber and its subtree. E.g.
147 // the ConcurrentMode flag indicates whether the subtree should be async-by-
@@ -237,7 +237,7 @@ function FiberNode(
237 this.memoizedProps = null;
238 this.updateQueue = null;
239 this.memoizedState = null;
240 - this.firstContextDependency = null;
240 + this.contextDependencies = null;
241
242 this.mode = mode;
243
@@ -403,7 +403,7 @@ export function createWorkInProgress(
403 workInProgress.memoizedProps = current.memoizedProps;
404 workInProgress.memoizedState = current.memoizedState;
405 workInProgress.updateQueue = current.updateQueue;
406 - workInProgress.firstContextDependency = current.firstContextDependency;
406 + workInProgress.contextDependencies = current.contextDependencies;
407
408 // These will be overridden during the parent's reconciliation
409 workInProgress.sibling = current.sibling;
@@ -704,7 +704,7 @@ export function assignFiberPropertiesInDEV(
704 target.memoizedProps = source.memoizedProps;
705 target.updateQueue = source.updateQueue;
706 target.memoizedState = source.memoizedState;
707 - target.firstContextDependency = source.firstContextDependency;
707 + target.contextDependencies = source.contextDependencies;
708 target.mode = source.mode;
709 target.effectTag = source.effectTag;
710 target.nextEffect = source.nextEffect;
packages/react-reconciler/src/ReactFiberBeginWork.js
+93 -26
@@ -90,7 +90,7 @@ import {
90 prepareToReadContext,
91 calculateChangedBits,
92 } from './ReactFiberNewContext';
93 -import {prepareToUseHooks, finishHooks, resetHooks} from './ReactFiberHooks';
93 +import {resetHooks, renderWithHooks, bailoutHooks} from './ReactFiberHooks';
94 import {stopProfilerTimerIfRunning} from './ReactProfilerTimer';
95 import {
96 getMaskedContext,
@@ -128,6 +128,8 @@ import {
128
129 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
130
131 +let didReceiveUpdate: boolean = false;
132 +
133 let didWarnAboutBadClass;
134 let didWarnAboutContextTypeOnFunctionComponent;
135 let didWarnAboutGetDerivedStateOnFunctionComponent;
@@ -237,16 +239,37 @@ function updateForwardRef(
239 // The rest is a fork of updateFunctionComponent
240 let nextChildren;
241 prepareToReadContext(workInProgress, renderExpirationTime);
240 - prepareToUseHooks(current, workInProgress, renderExpirationTime);
242 if (__DEV__) {
243 ReactCurrentOwner.current = workInProgress;
244 setCurrentPhase('render');
244 - nextChildren = render(nextProps, ref);
245 + nextChildren = renderWithHooks(
246 + current,
247 + workInProgress,
248 + render,
249 + nextProps,
250 + ref,
251 + renderExpirationTime,
252 + );
253 setCurrentPhase(null);
254 } else {
247 - nextChildren = render(nextProps, ref);
255 + nextChildren = renderWithHooks(
256 + current,
257 + workInProgress,
258 + render,
259 + nextProps,
260 + ref,
261 + renderExpirationTime,
262 + );
263 + }
264 +
265 + if (current !== null && !didReceiveUpdate) {
266 + bailoutHooks(current, workInProgress, renderExpirationTime);
267 + return bailoutOnAlreadyFinishedWork(
268 + current,
269 + workInProgress,
270 + renderExpirationTime,
271 + );
272 }
249 - nextChildren = finishHooks(render, nextProps, nextChildren, ref);
273
274 // React DevTools reads this flag.
275 workInProgress.effectTag |= PerformedWork;
@@ -395,17 +418,20 @@ function updateSimpleMemoComponent(
418 // Inner propTypes will be validated in the function component path.
419 }
420 }
398 - if (current !== null && updateExpirationTime < renderExpirationTime) {
421 + if (current !== null) {
422 const prevProps = current.memoizedProps;
423 if (
424 shallowEqual(prevProps, nextProps) &&
425 current.ref === workInProgress.ref
426 ) {
404 - return bailoutOnAlreadyFinishedWork(
405 - current,
406 - workInProgress,
407 - renderExpirationTime,
408 - );
427 + didReceiveUpdate = false;
428 + if (updateExpirationTime < renderExpirationTime) {
429 + return bailoutOnAlreadyFinishedWork(
430 + current,
431 + workInProgress,
432 + renderExpirationTime,
433 + );
434 + }
435 }
436 }
437 return updateFunctionComponent(
@@ -506,16 +532,37 @@ function updateFunctionComponent(
532
533 let nextChildren;
534 prepareToReadContext(workInProgress, renderExpirationTime);
509 - prepareToUseHooks(current, workInProgress, renderExpirationTime);
535 if (__DEV__) {
536 ReactCurrentOwner.current = workInProgress;
537 setCurrentPhase('render');
513 - nextChildren = Component(nextProps, context);
538 + nextChildren = renderWithHooks(
539 + current,
540 + workInProgress,
541 + Component,
542 + nextProps,
543 + context,
544 + renderExpirationTime,
545 + );
546 setCurrentPhase(null);
547 } else {
516 - nextChildren = Component(nextProps, context);
548 + nextChildren = renderWithHooks(
549 + current,
550 + workInProgress,
551 + Component,
552 + nextProps,
553 + context,
554 + renderExpirationTime,
555 + );
556 + }
557 +
558 + if (current !== null && !didReceiveUpdate) {
559 + bailoutHooks(current, workInProgress, renderExpirationTime);
560 + return bailoutOnAlreadyFinishedWork(
561 + current,
562 + workInProgress,
563 + renderExpirationTime,
564 + );
565 }
518 - nextChildren = finishHooks(Component, nextProps, nextChildren, context);
566
567 // React DevTools reads this flag.
568 workInProgress.effectTag |= PerformedWork;
@@ -850,7 +897,7 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) {
897 shouldDeprioritizeSubtree(type, nextProps)
898 ) {
899 // Schedule this fiber to re-render at offscreen priority. Then bailout.
853 - workInProgress.expirationTime = Never;
900 + workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
901 return null;
902 }
903
@@ -1063,7 +1110,6 @@ function mountIndeterminateComponent(
1110 const context = getMaskedContext(workInProgress, unmaskedContext);
1111
1112 prepareToReadContext(workInProgress, renderExpirationTime);
1066 - prepareToUseHooks(null, workInProgress, renderExpirationTime);
1113
1114 let value;
1115
@@ -1091,9 +1137,23 @@ function mountIndeterminateComponent(
1137 }
1138
1139 ReactCurrentOwner.current = workInProgress;
1094 - value = Component(props, context);
1140 + value = renderWithHooks(
1141 + null,
1142 + workInProgress,
1143 + Component,
1144 + props,
1145 + context,
1146 + renderExpirationTime,
1147 + );
1148 } else {
1096 - value = Component(props, context);
1149 + value = renderWithHooks(
1150 + null,
1151 + workInProgress,
1152 + Component,
1153 + props,
1154 + context,
1155 + renderExpirationTime,
1156 + );
1157 }
1158 // React DevTools reads this flag.
1159 workInProgress.effectTag |= PerformedWork;
@@ -1147,7 +1207,6 @@ function mountIndeterminateComponent(
1207 } else {
1208 // Proceed under the assumption that this is a function component
1209 workInProgress.tag = FunctionComponent;
1150 - value = finishHooks(Component, props, value, context);
1210 reconcileChildren(null, workInProgress, value, renderExpirationTime);
1211 if (__DEV__) {
1212 validateFunctionComponentInDev(workInProgress, Component);
@@ -1638,6 +1697,10 @@ function updateContextConsumer(
1697 return workInProgress.child;
1698 }
1699
1700 +export function markWorkInProgressReceivedUpdate() {
1701 + didReceiveUpdate = true;
1702 +}
1703 +
1704 function bailoutOnAlreadyFinishedWork(
1705 current: Fiber | null,
1706 workInProgress: Fiber,
@@ -1647,7 +1710,7 @@ function bailoutOnAlreadyFinishedWork(
1710
1711 if (current !== null) {
1712 // Reuse previous context list
1650 - workInProgress.firstContextDependency = current.firstContextDependency;
1713 + workInProgress.contextDependencies = current.contextDependencies;
1714 }
1715
1716 if (enableProfilerTimer) {
@@ -1680,11 +1743,13 @@ function beginWork(
1743 if (current !== null) {
1744 const oldProps = current.memoizedProps;
1745 const newProps = workInProgress.pendingProps;
1683 - if (
1684 - oldProps === newProps &&
1685 - !hasLegacyContextChanged() &&
1686 - updateExpirationTime < renderExpirationTime
1687 - ) {
1746 +
1747 + if (oldProps !== newProps || hasLegacyContextChanged()) {
1748 + // If props or context changed, mark the fiber as having performed work.
1749 + // This may be unset if the props are determined to be equal later (memo).
1750 + didReceiveUpdate = true;
1751 + } else if (updateExpirationTime < renderExpirationTime) {
1752 + didReceiveUpdate = false;
1753 // This fiber does not have any pending work. Bailout without entering
1754 // the begin phase. There's still some bookkeeping we that needs to be done
1755 // in this optimized path, mostly pushing stuff onto the stack.
@@ -1767,6 +1832,8 @@ function beginWork(
1832 renderExpirationTime,
1833 );
1834 }
1835 + } else {
1836 + didReceiveUpdate = false;
1837 }
1838
1839 // Before entering the begin phase, clear the expiration time.
packages/react-reconciler/src/ReactFiberCompleteWork.js
+4 -13
@@ -82,7 +82,6 @@ import {
82 prepareToHydrateHostTextInstance,
83 popHydrationState,
84 } from './ReactFiberHydrationContext';
85 -import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
85
86 function markUpdate(workInProgress: Fiber) {
87 // Tag the fiber with an update effect. This turns a Placement into
@@ -728,18 +727,10 @@ function completeWork(
727 }
728 }
729
731 - // The children either timed out after previously being visible, or
732 - // were restored after previously being hidden. Schedule an effect
733 - // to update their visiblity.
734 - if (
735 - //
736 - nextDidTimeout !== prevDidTimeout ||
737 - // Outside concurrent mode, the primary children commit in an
738 - // inconsistent state, even if they are hidden. So if they are hidden,
739 - // we need to schedule an effect to re-hide them, just in case.
740 - ((workInProgress.effectTag & ConcurrentMode) === NoContext &&
741 - nextDidTimeout)
742 - ) {
730 + if (nextDidTimeout || prevDidTimeout) {
731 + // If the children are hidden, or if they were previous hidden, schedule
732 + // an effect to toggle their visibility. This is also used to attach a
733 + // retry listener to the promise.
734 workInProgress.effectTag |= Update;
735 }
736 break;
packages/react-reconciler/src/ReactFiberHooks.js
+115 -48
@@ -35,24 +35,29 @@ import {
35
36 import invariant from 'shared/invariant';
37 import areHookInputsEqual from 'shared/areHookInputsEqual';
38 +import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
39
39 -type Update<A> = {
40 +type Update<S, A> = {
41 expirationTime: ExpirationTime,
42 action: A,
42 - next: Update<A> | null,
43 + eagerReducer: ((S, A) => S) | null,
44 + eagerState: S | null,
45 + next: Update<S, A> | null,
46 };
47
45 -type UpdateQueue<A> = {
46 - last: Update<A> | null,
47 - dispatch: any,
48 +type UpdateQueue<S, A> = {
49 + last: Update<S, A> | null,
50 + dispatch: (A => mixed) | null,
51 + eagerReducer: ((S, A) => S) | null,
52 + eagerState: S | null,
53 };
54
55 export type Hook = {
56 memoizedState: any,
57
58 baseState: any,
54 - baseUpdate: Update<any> | null,
55 - queue: UpdateQueue<any> | null,
59 + baseUpdate: Update<any, any> | null,
60 + queue: UpdateQueue<any, any> | null,
61
62 next: Hook | null,
63 };
@@ -104,9 +109,12 @@ let isReRender: boolean = false;
109 // Whether an update was scheduled during the currently executing render pass.
110 let didScheduleRenderPhaseUpdate: boolean = false;
111 // Lazily created map of render-phase updates
107 -let renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null = null;
112 +let renderPhaseUpdates: Map<
113 + UpdateQueue<any, any>,
114 + Update<any, any>,
115 +> | null = null;
116 // Counter to prevent infinite loops.
109 -let numberOfReRenders: number = 0;
117 +let numberOfReRenders: number = -1;
118 const RE_RENDER_LIMIT = 25;
119
120 function resolveCurrentlyRenderingFiber(): Fiber {
@@ -117,13 +125,16 @@ function resolveCurrentlyRenderingFiber(): Fiber {
125 return currentlyRenderingFiber;
126 }
127
120 -export function prepareToUseHooks(
128 +export function renderWithHooks(
129 current: Fiber | null,
130 workInProgress: Fiber,
131 + Component: any,
132 + props: any,
133 + refOrContext: any,
134 nextRenderExpirationTime: ExpirationTime,
124 -): void {
135 +): any {
136 if (!enableHooks) {
126 - return;
137 + return Component(props, refOrContext);
138 }
139 renderExpirationTime = nextRenderExpirationTime;
140 currentlyRenderingFiber = workInProgress;
@@ -139,27 +150,10 @@ export function prepareToUseHooks(
150 // isReRender = false;
151 // didScheduleRenderPhaseUpdate = false;
152 // renderPhaseUpdates = null;
142 - // numberOfReRenders = 0;
143 -}
144 -
145 -export function finishHooks(
146 - Component: any,
147 - props: any,
148 - children: any,
149 - refOrContext: any,
150 -): any {
151 - if (!enableHooks) {
152 - return children;
153 - }
154 -
155 - // This must be called after every function component to prevent hooks from
156 - // being used in classes.
153 + // numberOfReRenders = -1;
154
158 - while (didScheduleRenderPhaseUpdate) {
159 - // Updates were scheduled during the render phase. They are stored in
160 - // the `renderPhaseUpdates` map. Call the component again, reusing the
161 - // work-in-progress hooks and applying the additional updates on top. Keep
162 - // restarting until no more updates are scheduled.
155 + let children;
156 + do {
157 didScheduleRenderPhaseUpdate = false;
158 numberOfReRenders += 1;
159
@@ -169,15 +163,16 @@ export function finishHooks(
163 componentUpdateQueue = null;
164
165 children = Component(props, refOrContext);
172 - }
166 + } while (didScheduleRenderPhaseUpdate);
167 +
168 renderPhaseUpdates = null;
174 - numberOfReRenders = 0;
169 + numberOfReRenders = -1;
170
171 const renderedWork: Fiber = (currentlyRenderingFiber: any);
172
173 renderedWork.memoizedState = firstWorkInProgressHook;
174 renderedWork.expirationTime = remainingExpirationTime;
180 - renderedWork.updateQueue = (componentUpdateQueue: any);
175 + renderedWork.updateQueue = componentUpdateQueue;
176
177 const didRenderTooFewHooks =
178 currentHook !== null && currentHook.next !== null;
@@ -199,7 +194,7 @@ export function finishHooks(
194 // These were reset above
195 // didScheduleRenderPhaseUpdate = false;
196 // renderPhaseUpdates = null;
202 - // numberOfReRenders = 0;
197 + // numberOfReRenders = -1;
198
199 invariant(
200 !didRenderTooFewHooks,
@@ -210,14 +205,26 @@ export function finishHooks(
205 return children;
206 }
207
208 +export function bailoutHooks(
209 + current: Fiber,
210 + workInProgress: Fiber,
211 + expirationTime: ExpirationTime,
212 +) {
213 + workInProgress.updateQueue = current.updateQueue;
214 + workInProgress.effectTag &= ~(PassiveEffect | UpdateEffect);
215 + if (current.expirationTime <= expirationTime) {
216 + current.expirationTime = NoWork;
217 + }
218 +}
219 +
220 export function resetHooks(): void {
221 if (!enableHooks) {
222 return;
223 }
224
218 - // This is called instead of `finishHooks` if the component throws. It's also
219 - // called inside mountIndeterminateComponent if we determine the component
220 - // is a module-style component.
225 + // This is used to reset the state of this module when a component throws.
226 + // It's also called inside mountIndeterminateComponent if we determine the
227 + // component is a module-style component.
228 renderExpirationTime = NoWork;
229 currentlyRenderingFiber = null;
230
@@ -234,7 +241,7 @@ export function resetHooks(): void {
241
242 didScheduleRenderPhaseUpdate = false;
243 renderPhaseUpdates = null;
237 - numberOfReRenders = 0;
244 + numberOfReRenders = -1;
245 }
246
247 function createHook(): Hook {
@@ -347,7 +354,7 @@ export function useReducer<S, A>(
354 ): [S, Dispatch<A>] {
355 currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
356 workInProgressHook = createWorkInProgressHook();
350 - let queue: UpdateQueue<A> | null = (workInProgressHook.queue: any);
357 + let queue: UpdateQueue<S, A> | null = (workInProgressHook.queue: any);
358 if (queue !== null) {
359 // Already have a queue, so this is an update.
360 if (isReRender) {
@@ -390,6 +397,7 @@ export function useReducer<S, A>(
397 const last = queue.last;
398 // The last update that is part of the base state.
399 const baseUpdate = workInProgressHook.baseUpdate;
400 + const baseState = workInProgressHook.baseState;
401
402 // Find the first unprocessed update.
403 let first;
@@ -405,7 +413,7 @@ export function useReducer<S, A>(
413 first = last !== null ? last.next : null;
414 }
415 if (first !== null) {
408 - let newState = workInProgressHook.baseState;
416 + let newState = baseState;
417 let newBaseState = null;
418 let newBaseUpdate = null;
419 let prevUpdate = baseUpdate;
@@ -428,8 +436,14 @@ export function useReducer<S, A>(
436 }
437 } else {
438 // Process this update.
431 - const action = update.action;
432 - newState = reducer(newState, action);
439 + if (update.eagerReducer === reducer) {
440 + // If this update was processed eagerly, and its reducer matches the
441 + // current reducer, we can use the eagerly computed state.
442 + newState = ((update.eagerState: any): S);
443 + } else {
444 + const action = update.action;
445 + newState = reducer(newState, action);
446 + }
447 }
448 prevUpdate = update;
449 update = update.next;
@@ -443,6 +457,15 @@ export function useReducer<S, A>(
457 workInProgressHook.memoizedState = newState;
458 workInProgressHook.baseUpdate = newBaseUpdate;
459 workInProgressHook.baseState = newBaseState;
460 +
461 + // Mark that the fiber performed work, but only if the new state is
462 + // different from the current state.
463 + if (newState !== (currentHook: any).memoizedState) {
464 + markWorkInProgressReceivedUpdate();
465 + }
466 +
467 + queue.eagerReducer = reducer;
468 + queue.eagerState = newState;
469 }
470
471 const dispatch: Dispatch<A> = (queue.dispatch: any);
@@ -462,6 +485,8 @@ export function useReducer<S, A>(
485 queue = workInProgressHook.queue = {
486 last: null,
487 dispatch: null,
488 + eagerReducer: reducer,
489 + eagerState: initialState,
490 };
491 const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
492 null,
@@ -644,7 +669,11 @@ export function useMemo<T>(
669 return nextValue;
670 }
671
647 -function dispatchAction<A>(fiber: Fiber, queue: UpdateQueue<A>, action: A) {
672 +function dispatchAction<S, A>(
673 + fiber: Fiber,
674 + queue: UpdateQueue<S, A>,
675 + action: A,
676 +) {
677 invariant(
678 numberOfReRenders < RE_RENDER_LIMIT,
679 'Too many re-renders. React limits the number of renders to prevent ' +
@@ -660,9 +689,11 @@ function dispatchAction<A>(fiber: Fiber, queue: UpdateQueue<A>, action: A) {
689 // queue -> linked list of updates. After this render pass, we'll restart
690 // and apply the stashed updates on top of the work-in-progress hook.
691 didScheduleRenderPhaseUpdate = true;
663 - const update: Update<A> = {
692 + const update: Update<S, A> = {
693 expirationTime: renderExpirationTime,
694 action,
695 + eagerReducer: null,
696 + eagerState: null,
697 next: null,
698 };
699 if (renderPhaseUpdates === null) {
@@ -680,14 +711,19 @@ function dispatchAction<A>(fiber: Fiber, queue: UpdateQueue<A>, action: A) {
711 lastRenderPhaseUpdate.next = update;
712 }
713 } else {
714 + flushPassiveEffects();
715 +
716 const currentTime = requestCurrentTime();
717 const expirationTime = computeExpirationForFiber(currentTime, fiber);
685 - const update: Update<A> = {
718 +
719 + const update: Update<S, A> = {
720 expirationTime,
721 action,
722 + eagerReducer: null,
723 + eagerState: null,
724 next: null,
725 };
690 - flushPassiveEffects();
726 +
727 // Append the update to the end of the list.
728 const last = queue.last;
729 if (last === null) {
@@ -702,6 +738,37 @@ function dispatchAction<A>(fiber: Fiber, queue: UpdateQueue<A>, action: A) {
738 last.next = update;
739 }
740 queue.last = update;
741 +
742 + if (
743 + fiber.expirationTime === NoWork &&
744 + (alternate === null || alternate.expirationTime === NoWork)
745 + ) {
746 + // The queue is currently empty, which means we can eagerly compute the
747 + // next state before entering the render phase. If the new state is the
748 + // same as the current state, we may be able to bail out entirely.
749 + const eagerReducer = queue.eagerReducer;
750 + if (eagerReducer !== null) {
751 + try {
752 + const currentState: S = (queue.eagerState: any);
753 + const eagerState = eagerReducer(currentState, action);
754 + // Stash the eagerly computed state, and the reducer used to compute
755 + // it, on the update object. If the reducer hasn't changed by the
756 + // time we enter the render phase, then the eager state can be used
757 + // without calling the reducer again.
758 + update.eagerReducer = eagerReducer;
759 + update.eagerState = eagerState;
760 + if (eagerState === currentState) {
761 + // Fast path. We can bail out without scheduling React to re-render.
762 + // It's still possible that we'll need to rebase this update later,
763 + // if the component re-renders for a different reason and by that
764 + // time the reducer has changed.
765 + return;
766 + }
767 + } catch (error) {
768 + // Suppress the error. It will throw again in the render phase.
769 + }
770 + }
771 + }
772 scheduleWork(fiber, expirationTime);
773 }
774 }
packages/react-reconciler/src/ReactFiberNewContext.js
+41 -9
@@ -12,7 +12,12 @@ import type {Fiber} from './ReactFiber';
12 import type {StackCursor} from './ReactFiberStack';
13 import type {ExpirationTime} from './ReactFiberExpirationTime';
14
15 -export type ContextDependency<T> = {
15 +export type ContextDependencyList = {
16 + first: ContextDependency<mixed>,
17 + expirationTime: ExpirationTime,
18 +};
19 +
20 +type ContextDependency<T> = {
21 context: ReactContext<T>,
22 observedBits: number,
23 next: ContextDependency<mixed> | null,
@@ -32,6 +37,8 @@ import {
37 enqueueUpdate,
38 ForceUpdate,
39 } from 'react-reconciler/src/ReactUpdateQueue';
40 +import {NoWork} from './ReactFiberExpirationTime';
41 +import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
42
43 const valueCursor: StackCursor<mixed> = createCursor(null);
44
@@ -141,9 +148,12 @@ export function propagateContextChange(
148 let nextFiber;
149
150 // Visit this fiber.
144 - let dependency = fiber.firstContextDependency;
145 - if (dependency !== null) {
146 - do {
151 + const list = fiber.contextDependencies;
152 + if (list !== null) {
153 + nextFiber = fiber.child;
154 +
155 + let dependency = list.first;
156 + while (dependency !== null) {
157 // Check if the context matches.
158 if (
159 dependency.context === context &&
@@ -197,10 +207,18 @@ export function propagateContextChange(
207 }
208 node = node.return;
209 }
210 +
211 + // Mark the expiration time on the list, too.
212 + if (list.expirationTime < renderExpirationTime) {
213 + list.expirationTime = renderExpirationTime;
214 + }
215 +
216 + // Since we already found a match, we can stop traversing the
217 + // dependency list.
218 + break;
219 }
201 - nextFiber = fiber.child;
220 dependency = dependency.next;
203 - } while (dependency !== null);
221 + }
222 } else if (fiber.tag === ContextProvider) {
223 // Don't scan deeper if this is a matching provider
224 nextFiber = fiber.type === workInProgress.type ? null : fiber.child;
@@ -244,8 +262,17 @@ export function prepareToReadContext(
262 lastContextDependency = null;
263 lastContextWithAllBitsObserved = null;
264
265 + const currentDependencies = workInProgress.contextDependencies;
266 + if (
267 + currentDependencies !== null &&
268 + currentDependencies.expirationTime >= renderExpirationTime
269 + ) {
270 + // Context list has a pending update. Mark that this fiber performed work.
271 + markWorkInProgressReceivedUpdate();
272 + }
273 +
274 // Reset the work-in-progress list
248 - workInProgress.firstContextDependency = null;
275 + workInProgress.contextDependencies = null;
276 }
277
278 export function readContext<T>(
@@ -281,8 +308,13 @@ export function readContext<T>(
308 'Context can only be read while React is ' +
309 'rendering, e.g. inside the render method or getDerivedStateFromProps.',
310 );
284 - // This is the first dependency in the list
285 - currentlyRenderingFiber.firstContextDependency = lastContextDependency = contextItem;
311 +
312 + // This is the first dependency for this component. Create a new list.
313 + lastContextDependency = contextItem;
314 + currentlyRenderingFiber.contextDependencies = {
315 + first: contextItem,
316 + expirationTime: NoWork,
317 + };
318 } else {
319 // Append a new context item.
320 lastContextDependency = lastContextDependency.next = contextItem;
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+384
@@ -48,6 +48,390 @@ describe('ReactHooks', () => {
48 });
49 }
50
51 + it('bails out in the render phase if all of the state is the same', () => {
52 + const {useState, useLayoutEffect} = React;
53 +
54 + function Child({text}) {
55 + ReactTestRenderer.unstable_yield('Child: ' + text);
56 + return text;
57 + }
58 +
59 + let setCounter1;
60 + let setCounter2;
61 + function Parent() {
62 + const [counter1, _setCounter1] = useState(0);
63 + setCounter1 = _setCounter1;
64 + const [counter2, _setCounter2] = useState(0);
65 + setCounter2 = _setCounter2;
66 +
67 + const text = `${counter1}, ${counter2}`;
68 + ReactTestRenderer.unstable_yield(`Parent: ${text}`);
69 + useLayoutEffect(() => {
70 + ReactTestRenderer.unstable_yield(`Effect: ${text}`);
71 + });
72 + return <Child text={text} />;
73 + }
74 +
75 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
76 + root.update(<Parent />);
77 + expect(root).toFlushAndYield([
78 + 'Parent: 0, 0',
79 + 'Child: 0, 0',
80 + 'Effect: 0, 0',
81 + ]);
82 + expect(root).toMatchRenderedOutput('0, 0');
83 +
84 + // Normal update
85 + setCounter1(1);
86 + setCounter2(1);
87 + expect(root).toFlushAndYield([
88 + 'Parent: 1, 1',
89 + 'Child: 1, 1',
90 + 'Effect: 1, 1',
91 + ]);
92 +
93 + // Update that bails out.
94 + setCounter1(1);
95 + expect(root).toFlushAndYield(['Parent: 1, 1']);
96 +
97 + // This time, one of the state updates but the other one doesn't. So we
98 + // can't bail out.
99 + setCounter1(1);
100 + setCounter2(2);
101 + expect(root).toFlushAndYield([
102 + 'Parent: 1, 2',
103 + 'Child: 1, 2',
104 + 'Effect: 1, 2',
105 + ]);
106 +
107 + // Lots of updates that eventually resolve to the current values.
108 + setCounter1(9);
109 + setCounter2(3);
110 + setCounter1(4);
111 + setCounter2(7);
112 + setCounter1(1);
113 + setCounter2(2);
114 +
115 + // Because the final values are the same as the current values, the
116 + // component bails out.
117 + expect(root).toFlushAndYield(['Parent: 1, 2']);
118 + });
119 +
120 + it('bails out in render phase if all the state is the same and props bail out with memo', () => {
121 + const {useState, memo} = React;
122 +
123 + function Child({text}) {
124 + ReactTestRenderer.unstable_yield('Child: ' + text);
125 + return text;
126 + }
127 +
128 + let setCounter1;
129 + let setCounter2;
130 + function Parent({theme}) {
131 + const [counter1, _setCounter1] = useState(0);
132 + setCounter1 = _setCounter1;
133 + const [counter2, _setCounter2] = useState(0);
134 + setCounter2 = _setCounter2;
135 +
136 + const text = `${counter1}, ${counter2} (${theme})`;
137 + ReactTestRenderer.unstable_yield(`Parent: ${text}`);
138 + return <Child text={text} />;
139 + }
140 +
141 + Parent = memo(Parent);
142 +
143 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
144 + root.update(<Parent theme="light" />);
145 + expect(root).toFlushAndYield([
146 + 'Parent: 0, 0 (light)',
147 + 'Child: 0, 0 (light)',
148 + ]);
149 + expect(root).toMatchRenderedOutput('0, 0 (light)');
150 +
151 + // Normal update
152 + setCounter1(1);
153 + setCounter2(1);
154 + expect(root).toFlushAndYield([
155 + 'Parent: 1, 1 (light)',
156 + 'Child: 1, 1 (light)',
157 + ]);
158 +
159 + // Update that bails out.
160 + setCounter1(1);
161 + expect(root).toFlushAndYield(['Parent: 1, 1 (light)']);
162 +
163 + // This time, one of the state updates but the other one doesn't. So we
164 + // can't bail out.
165 + setCounter1(1);
166 + setCounter2(2);
167 + expect(root).toFlushAndYield([
168 + 'Parent: 1, 2 (light)',
169 + 'Child: 1, 2 (light)',
170 + ]);
171 +
172 + // Updates bail out, but component still renders because props
173 + // have changed
174 + setCounter1(1);
175 + setCounter2(2);
176 + root.update(<Parent theme="dark" />);
177 + expect(root).toFlushAndYield(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']);
178 +
179 + // Both props and state bail out
180 + setCounter1(1);
181 + setCounter2(2);
182 + root.update(<Parent theme="dark" />);
183 + expect(root).toFlushAndYield(['Parent: 1, 2 (dark)']);
184 + });
185 +
186 + it('never bails out if context has changed', () => {
187 + const {useState, useLayoutEffect, useContext} = React;
188 +
189 + const ThemeContext = React.createContext('light');
190 +
191 + let setTheme;
192 + function ThemeProvider({children}) {
193 + const [theme, _setTheme] = useState('light');
194 + ReactTestRenderer.unstable_yield('Theme: ' + theme);
195 + setTheme = _setTheme;
196 + return (
197 + <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
198 + );
199 + }
200 +
201 + function Child({text}) {
202 + ReactTestRenderer.unstable_yield('Child: ' + text);
203 + return text;
204 + }
205 +
206 + let setCounter;
207 + function Parent() {
208 + const [counter, _setCounter] = useState(0);
209 + setCounter = _setCounter;
210 +
211 + const theme = useContext(ThemeContext);
212 +
213 + const text = `${counter} (${theme})`;
214 + ReactTestRenderer.unstable_yield(`Parent: ${text}`);
215 + useLayoutEffect(() => {
216 + ReactTestRenderer.unstable_yield(`Effect: ${text}`);
217 + });
218 + return <Child text={text} />;
219 + }
220 +
221 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
222 + root.update(
223 + <ThemeProvider>
224 + <Parent />
225 + </ThemeProvider>,
226 + );
227 + expect(root).toFlushAndYield([
228 + 'Theme: light',
229 + 'Parent: 0 (light)',
230 + 'Child: 0 (light)',
231 + 'Effect: 0 (light)',
232 + ]);
233 + expect(root).toMatchRenderedOutput('0 (light)');
234 +
235 + // Updating the theme to the same value does't cause the consumers
236 + // to re-render.
237 + setTheme('light');
238 + expect(root).toFlushAndYield([]);
239 + expect(root).toMatchRenderedOutput('0 (light)');
240 +
241 + // Normal update
242 + setCounter(1);
243 + expect(root).toFlushAndYield([
244 + 'Parent: 1 (light)',
245 + 'Child: 1 (light)',
246 + 'Effect: 1 (light)',
247 + ]);
248 + expect(root).toMatchRenderedOutput('1 (light)');
249 +
250 + // Update that doesn't change state, so it bails out
251 + setCounter(1);
252 + expect(root).toFlushAndYield(['Parent: 1 (light)']);
253 + expect(root).toMatchRenderedOutput('1 (light)');
254 +
255 + // Update that doesn't change state, but the context changes, too, so it
256 + // can't bail out
257 + setCounter(1);
258 + setTheme('dark');
259 + expect(root).toFlushAndYield([
260 + 'Theme: dark',
261 + 'Parent: 1 (dark)',
262 + 'Child: 1 (dark)',
263 + 'Effect: 1 (dark)',
264 + ]);
265 + expect(root).toMatchRenderedOutput('1 (dark)');
266 + });
267 +
268 + it('can bail out without calling render phase (as an optimization) if queue is known to be empty', () => {
269 + const {useState, useLayoutEffect} = React;
270 +
271 + function Child({text}) {
272 + ReactTestRenderer.unstable_yield('Child: ' + text);
273 + return text;
274 + }
275 +
276 + let setCounter;
277 + function Parent() {
278 + const [counter, _setCounter] = useState(0);
279 + setCounter = _setCounter;
280 + ReactTestRenderer.unstable_yield('Parent: ' + counter);
281 + useLayoutEffect(() => {
282 + ReactTestRenderer.unstable_yield('Effect: ' + counter);
283 + });
284 + return <Child text={counter} />;
285 + }
286 +
287 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
288 + root.update(<Parent />);
289 + expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
290 + expect(root).toMatchRenderedOutput('0');
291 +
292 + // Normal update
293 + setCounter(1);
294 + expect(root).toFlushAndYield(['Parent: 1', 'Child: 1', 'Effect: 1']);
295 + expect(root).toMatchRenderedOutput('1');
296 +
297 + // Update to the same state. React doesn't know if the queue is empty
298 + // because the alterate fiber has pending update priority, so we have to
299 + // enter the render phase before we can bail out. But we bail out before
300 + // rendering the child, and we don't fire any effects.
301 + setCounter(1);
302 + expect(root).toFlushAndYield(['Parent: 1']);
303 + expect(root).toMatchRenderedOutput('1');
304 +
305 + // Update to the same state again. This times, neither fiber has pending
306 + // update priority, so we can bail out before even entering the render phase.
307 + setCounter(1);
308 + expect(root).toFlushAndYield([]);
309 + expect(root).toMatchRenderedOutput('1');
310 +
311 + // This changes the state to something different so it renders normally.
312 + setCounter(2);
313 + expect(root).toFlushAndYield(['Parent: 2', 'Child: 2', 'Effect: 2']);
314 + expect(root).toMatchRenderedOutput('2');
315 + });
316 +
317 + it('bails out multiple times in a row without entering render phase', () => {
318 + const {useState} = React;
319 +
320 + function Child({text}) {
321 + ReactTestRenderer.unstable_yield('Child: ' + text);
322 + return text;
323 + }
324 +
325 + let setCounter;
326 + function Parent() {
327 + const [counter, _setCounter] = useState(0);
328 + setCounter = _setCounter;
329 + ReactTestRenderer.unstable_yield('Parent: ' + counter);
330 + return <Child text={counter} />;
331 + }
332 +
333 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
334 + root.update(<Parent />);
335 + expect(root).toFlushAndYield(['Parent: 0', 'Child: 0']);
336 + expect(root).toMatchRenderedOutput('0');
337 +
338 + const update = value => {
339 + setCounter(previous => {
340 + ReactTestRenderer.unstable_yield(
341 + `Compute state (${previous} -> ${value})`,
342 + );
343 + return value;
344 + });
345 + };
346 + update(0);
347 + update(0);
348 + update(0);
349 + update(1);
350 + update(2);
351 + update(3);
352 +
353 + expect(ReactTestRenderer).toHaveYielded([
354 + // The first four updates were eagerly computed, because the queue is
355 + // empty before each one.
356 + 'Compute state (0 -> 0)',
357 + 'Compute state (0 -> 0)',
358 + 'Compute state (0 -> 0)',
359 + // The fourth update doesn't bail out
360 + 'Compute state (0 -> 1)',
361 + // so subsequent updates can't be eagerly computed.
362 + ]);
363 +
364 + // Now let's enter the render phase
365 + expect(root).toFlushAndYield([
366 + // We don't need to re-compute the first four updates. Only the final two.
367 + 'Compute state (1 -> 2)',
368 + 'Compute state (2 -> 3)',
369 + 'Parent: 3',
370 + 'Child: 3',
371 + ]);
372 + expect(root).toMatchRenderedOutput('3');
373 + });
374 +
375 + it('can rebase on top of a previously skipped update', () => {
376 + const {useState} = React;
377 +
378 + function Child({text}) {
379 + ReactTestRenderer.unstable_yield('Child: ' + text);
380 + return text;
381 + }
382 +
383 + let setCounter;
384 + function Parent() {
385 + const [counter, _setCounter] = useState(1);
386 + setCounter = _setCounter;
387 + ReactTestRenderer.unstable_yield('Parent: ' + counter);
388 + return <Child text={counter} />;
389 + }
390 +
391 + const root = ReactTestRenderer.create(null, {unstable_isConcurrent: true});
392 + root.update(<Parent />);
393 + expect(root).toFlushAndYield(['Parent: 1', 'Child: 1']);
394 + expect(root).toMatchRenderedOutput('1');
395 +
396 + const update = compute => {
397 + setCounter(previous => {
398 + const value = compute(previous);
399 + ReactTestRenderer.unstable_yield(
400 + `Compute state (${previous} -> ${value})`,
401 + );
402 + return value;
403 + });
404 + };
405 +
406 + // Update at normal priority
407 + update(n => n * 100);
408 +
409 + // The new state is eagerly computed.
410 + expect(ReactTestRenderer).toHaveYielded(['Compute state (1 -> 100)']);
411 +
412 + // but before it's flushed, a higher priority update interrupts it.
413 + root.unstable_flushSync(() => {
414 + update(n => n + 5);
415 + });
416 + expect(ReactTestRenderer).toHaveYielded([
417 + // The eagerly computed state was completely skipped
418 + 'Compute state (1 -> 6)',
419 + 'Parent: 6',
420 + 'Child: 6',
421 + ]);
422 + expect(root).toMatchRenderedOutput('6');
423 +
424 + // Now when we finish the first update, the second update is rebased on top.
425 + // Notice we didn't have to recompute the first update even though it was
426 + // skipped in the previous render.
427 + expect(root).toFlushAndYield([
428 + 'Compute state (100 -> 105)',
429 + 'Parent: 105',
430 + 'Child: 105',
431 + ]);
432 + expect(root).toMatchRenderedOutput('105');
433 + });
434 +
435 it('warns about variable number of dependencies', () => {
436 const {useLayoutEffect} = React;
437 function App(props) {
packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js
+31
@@ -883,6 +883,37 @@ describe('ReactNewContext', () => {
883 expect(ReactNoop.getChildren()).toEqual([span(2), span(2)]);
884 });
885
886 + it("context consumer doesn't bail out inside hidden subtree", () => {
887 + const Context = React.createContext('dark');
888 + const Consumer = getConsumer(Context);
889 +
890 + function App({theme}) {
891 + return (
892 + <Context.Provider value={theme}>
893 + <div hidden={true}>
894 + <Consumer>{value => <Text text={value} />}</Consumer>
895 + </div>
896 + </Context.Provider>
897 + );
898 + }
899 +
900 + ReactNoop.render(<App theme="dark" />);
901 + expect(ReactNoop.flush()).toEqual(['dark']);
902 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
903 + <div hidden={true}>
904 + <span prop="dark" />
905 + </div>,
906 + );
907 +
908 + ReactNoop.render(<App theme="light" />);
909 + expect(ReactNoop.flush()).toEqual(['light']);
910 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
911 + <div hidden={true}>
912 + <span prop="light" />
913 + </div>,
914 + );
915 + });
916 +
917 // This is a regression case for https://github.com/facebook/react/issues/12389.
918 it('does not run into an infinite loop', () => {
919 const Context = React.createContext(null);