@samitouri / QOS-React / commits / 11d0781eea

Defer useEffect until after paint

Effects scheduled by useEffect should not fire until after the browser has had a chance to paint. However, they should be fired before any subsequent mutations. Also adds useMutationEffect and useLayoutEffect. useMutationEffect fires during the host update phase. useLayoutEffect fires during the post- update phase (the same phase as componentDidMount and componentDidUpdate).

Andrew Clark committed Sep 23, 2018 at 18:12 UTC 11d0781eea7dfa65e0ea9e54d18ff937122f9524
11 files changed +966 -253
packages/react-reconciler/src/ReactFiberCommitWork.js
+65 -114
@@ -86,6 +86,16 @@ import {
86 requestCurrentTime,
87 scheduleWork,
88 } from './ReactFiberScheduler';
89 +import {
90 + NoEffect as NoHookEffect,
91 + UnmountSnapshot,
92 + UnmountMutation,
93 + MountMutation,
94 + UnmountLayout,
95 + MountLayout,
96 + UnmountPassive,
97 + MountPassive,
98 +} from './ReactHookEffectTags';
99
100 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
101 if (__DEV__) {
@@ -204,6 +214,11 @@ function commitBeforeMutationLifeCycles(
214 finishedWork: Fiber,
215 ): void {
216 switch (finishedWork.tag) {
217 + case FunctionComponent:
218 + case ForwardRef: {
219 + commitHookEffectList(UnmountSnapshot, NoHookEffect, finishedWork);
220 + return;
221 + }
222 case ClassComponent: {
223 if (finishedWork.effectTag & Snapshot) {
224 if (current !== null) {
@@ -254,26 +269,39 @@ function commitBeforeMutationLifeCycles(
269 }
270 }
271
257 -function destroyRemainingEffects(firstToDestroy, stopAt) {
258 - let effect = firstToDestroy;
259 - do {
260 - const destroy = effect.value;
261 - if (destroy !== null) {
262 - destroy();
263 - }
264 - effect = effect.next;
265 - } while (effect !== stopAt);
272 +function commitHookEffectList(
273 + unmountTag: number,
274 + mountTag: number,
275 + finishedWork: Fiber,
276 +) {
277 + const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
278 + let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
279 + if (lastEffect !== null) {
280 + const firstEffect = lastEffect.next;
281 + let effect = firstEffect;
282 + do {
283 + if ((effect.tag & unmountTag) !== NoHookEffect) {
284 + // Unmount
285 + const destroy = effect.destroy;
286 + effect.destroy = null;
287 + if (destroy !== null) {
288 + destroy();
289 + }
290 + }
291 + if ((effect.tag & mountTag) !== NoHookEffect) {
292 + // Mount
293 + const create = effect.create;
294 + const destroy = create();
295 + effect.destroy = typeof destroy === 'function' ? destroy : null;
296 + }
297 + effect = effect.next;
298 + } while (effect !== firstEffect);
299 + }
300 }
301
268 -function destroyMountedEffects(current) {
269 - const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
270 - if (oldUpdateQueue !== null) {
271 - const oldLastEffect = oldUpdateQueue.lastEffect;
272 - if (oldLastEffect !== null) {
273 - const oldFirstEffect = oldLastEffect.next;
274 - destroyRemainingEffects(oldFirstEffect, oldFirstEffect);
275 - }
276 - }
302 +export function commitPassiveHookEffects(finishedWork: Fiber): void {
303 + commitHookEffectList(UnmountPassive, NoHookEffect, finishedWork);
304 + commitHookEffectList(NoHookEffect, MountPassive, finishedWork);
305 }
306
307 function commitLifeCycles(
@@ -285,98 +313,12 @@ function commitLifeCycles(
313 switch (finishedWork.tag) {
314 case FunctionComponent:
315 case ForwardRef: {
288 - const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
289 - if (updateQueue !== null) {
290 - // Mount new effects and destroy the old ones by comparing to the
291 - // current list of effects. This could be a bit simpler if we avoided
292 - // the need to compare to the previous effect list by transferring the
293 - // old `destroy` method to the new effect during the render phase.
294 - // That's how I originally implemented it, but it requires an additional
295 - // field on the effect object.
296 - //
297 - // This supports removing effects from the end of the list. If we adopt
298 - // the constraint that hooks are append only, that would also save a bit
299 - // on code size.
300 - const newLastEffect = updateQueue.lastEffect;
301 - if (newLastEffect !== null) {
302 - const newFirstEffect = newLastEffect.next;
303 - let oldLastEffect = null;
304 - if (current !== null) {
305 - const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
306 - if (oldUpdateQueue !== null) {
307 - oldLastEffect = oldUpdateQueue.lastEffect;
308 - }
309 - }
310 - if (oldLastEffect !== null) {
311 - const oldFirstEffect = oldLastEffect.next;
312 - let newEffect = newFirstEffect;
313 - let oldEffect = oldFirstEffect;
314 -
315 - // Before mounting the new effects, unmount all the old ones.
316 - do {
317 - if (oldEffect !== null) {
318 - if (newEffect.inputs !== oldEffect.inputs) {
319 - const destroy = oldEffect.value;
320 - if (destroy !== null) {
321 - destroy();
322 - }
323 - }
324 - oldEffect = oldEffect.next;
325 - if (oldEffect === oldFirstEffect) {
326 - oldEffect = null;
327 - }
328 - }
329 - newEffect = newEffect.next;
330 - } while (newEffect !== newFirstEffect);
331 -
332 - // Unmount any remaining effects in the old list that do not
333 - // appear in the new one.
334 - if (oldEffect !== null) {
335 - destroyRemainingEffects(oldEffect, oldFirstEffect);
336 - }
337 -
338 - // Now loop through the list again to mount the new effects
339 - oldEffect = oldFirstEffect;
340 - do {
341 - const create = newEffect.value;
342 - if (oldEffect !== null) {
343 - if (newEffect.inputs !== oldEffect.inputs) {
344 - const newDestroy = create();
345 - newEffect.value =
346 - typeof newDestroy === 'function' ? newDestroy : null;
347 - } else {
348 - newEffect.value = oldEffect.value;
349 - }
350 - oldEffect = oldEffect.next;
351 - if (oldEffect === oldFirstEffect) {
352 - oldEffect = null;
353 - }
354 - } else {
355 - const newDestroy = create();
356 - newEffect.value =
357 - typeof newDestroy === 'function' ? newDestroy : null;
358 - }
359 - newEffect = newEffect.next;
360 - } while (newEffect !== newFirstEffect);
361 - } else {
362 - let newEffect = newFirstEffect;
363 - do {
364 - const create = newEffect.value;
365 - const newDestroy = create();
366 - newEffect.value =
367 - typeof newDestroy === 'function' ? newDestroy : null;
368 - newEffect = newEffect.next;
369 - } while (newEffect !== newFirstEffect);
370 - }
371 - } else if (current !== null) {
372 - // There are no effects, which means all current effects must
373 - // be destroyed
374 - destroyMountedEffects(current);
375 - }
376 -
377 - const callbackList = updateQueue.callbackList;
316 + commitHookEffectList(UnmountLayout, MountLayout, finishedWork);
317 + const newUpdateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
318 + if (newUpdateQueue !== null) {
319 + const callbackList = newUpdateQueue.callbackList;
320 if (callbackList !== null) {
379 - updateQueue.callbackList = null;
321 + newUpdateQueue.callbackList = null;
322 for (let i = 0; i < callbackList.length; i++) {
323 const update = callbackList[i];
324 // Assume this is non-null, since otherwise it would not be part
@@ -386,10 +328,6 @@ function commitLifeCycles(
328 callback();
329 }
330 }
389 - } else if (current !== null) {
390 - // There are no effects, which means all current effects must
391 - // be destroyed
392 - destroyMountedEffects(current);
331 }
332 break;
333 }
@@ -656,7 +594,7 @@ function commitUnmount(current: Fiber): void {
594 const firstEffect = lastEffect.next;
595 let effect = firstEffect;
596 do {
659 - const destroy = effect.value;
597 + const destroy = effect.destroy;
598 if (destroy !== null) {
599 safelyCallDestroy(current, destroy);
600 }
@@ -1039,11 +977,24 @@ function commitDeletion(current: Fiber): void {
977
978 function commitWork(current: Fiber | null, finishedWork: Fiber): void {
979 if (!supportsMutation) {
980 + switch (finishedWork.tag) {
981 + case FunctionComponent:
982 + case ForwardRef: {
983 + commitHookEffectList(UnmountMutation, MountMutation, finishedWork);
984 + return;
985 + }
986 + }
987 +
988 commitContainer(finishedWork);
989 return;
990 }
991
992 switch (finishedWork.tag) {
993 + case FunctionComponent:
994 + case ForwardRef: {
995 + commitHookEffectList(UnmountMutation, MountMutation, finishedWork);
996 + return;
997 + }
998 case ClassComponent: {
999 return;
1000 }
packages/react-reconciler/src/ReactFiberDispatcher.js
+14 -8
@@ -9,22 +9,28 @@
9
10 import {readContext} from './ReactFiberNewContext';
11 import {
12 - useState,
13 - useReducer,
14 - useEffect,
12 + useAPI,
13 useCallback,
14 + useContext,
15 + useEffect,
16 + useLayoutEffect,
17 useMemo,
18 + useMutationEffect,
19 + useReducer,
20 useRef,
18 - useAPI,
21 + useState,
22 } from './ReactFiberHooks';
23
24 export const Dispatcher = {
25 readContext,
23 - useState,
24 - useReducer,
25 - useEffect,
26 + useAPI,
27 useCallback,
28 + useContext,
29 + useEffect,
30 + useLayoutEffect,
31 useMemo,
32 + useMutationEffect,
33 + useReducer,
34 useRef,
29 - useAPI,
35 + useState,
36 };
packages/react-reconciler/src/ReactFiberHooks.js
+100 -31
@@ -7,11 +7,28 @@
7 * @flow
8 */
9
10 +import type {ReactContext} from 'shared/ReactTypes';
11 import type {Fiber} from './ReactFiber';
12 import type {ExpirationTime} from './ReactFiberExpirationTime';
13 +import type {HookEffectTag} from './ReactHookEffectTags';
14
15 import {NoWork} from './ReactFiberExpirationTime';
14 -import {Callback as CallbackEffect} from 'shared/ReactSideEffectTags';
16 +import {readContext} from './ReactFiberNewContext';
17 +import {
18 + Snapshot as SnapshotEffect,
19 + Update as UpdateEffect,
20 + Callback as CallbackEffect,
21 + Passive as PassiveEffect,
22 +} from 'shared/ReactSideEffectTags';
23 +import {
24 + NoEffect as NoHookEffect,
25 + UnmountSnapshot,
26 + UnmountMutation,
27 + MountMutation,
28 + MountLayout,
29 + UnmountPassive,
30 + MountPassive,
31 +} from './ReactHookEffectTags';
32 import {
33 scheduleWork,
34 computeExpirationForFiber,
@@ -43,10 +60,9 @@ type Hook = {
60 };
61
62 type Effect = {
46 - // For an unmounted effect, this points to the effect constructor. Once it's
47 - // mounted, it points to a destroy function (or null). I've opted to reuse
48 - // the same field to save memory.
49 - value: any,
63 + tag: HookEffectTag,
64 + create: () => mixed,
65 + destroy: (() => mixed) | null,
66 inputs: Array<mixed>,
67 next: Effect,
68 };
@@ -166,6 +182,9 @@ export function finishHooks(
182 renderedWork.updateQueue = (componentUpdateQueue: any);
183 }
184
185 + const didRenderTooFewHooks =
186 + currentHook !== null && currentHook.next !== null;
187 +
188 renderExpirationTime = NoWork;
189 currentlyRenderingFiber = null;
190
@@ -185,6 +204,12 @@ export function finishHooks(
204 // renderPhaseUpdates = null;
205 // numberOfReRenders = 0;
206
207 + invariant(
208 + !didRenderTooFewHooks,
209 + 'Rendered fewer hooks than expected. This may be caused by an accidental ' +
210 + 'early return statement.',
211 + );
212 +
213 return children;
214 }
215
@@ -295,6 +320,16 @@ function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
320 return typeof action === 'function' ? action(state) : action;
321 }
322
323 +export function useContext<T>(
324 + context: ReactContext<T>,
325 + observedBits: void | number | boolean,
326 +): T {
327 + // Ensure we're in a functional component (class components support only the
328 + // .unstable_read() form)
329 + resolveCurrentlyRenderingFiber();
330 + return readContext(context, observedBits);
331 +}
332 +
333 export function useState<S>(
334 initialState: S | (() => S),
335 ): [S, Dispatch<S, BasicStateAction<S>>] {
@@ -460,9 +495,11 @@ function pushCallback(workInProgress: Fiber, update: Update<any, any>): void {
495 workInProgress.effectTag |= CallbackEffect;
496 }
497
463 -function pushEffect(value, inputs) {
498 +function pushEffect(tag, create, destroy, inputs) {
499 const effect: Effect = {
465 - value,
500 + tag,
501 + create,
502 + destroy,
503 inputs,
504 // Circular
505 next: (null: any),
@@ -500,10 +537,38 @@ export function useRef<T>(initialValue: T): {current: T} {
537 return ref;
538 }
539
540 +export function useMutationEffect(
541 + create: () => mixed,
542 + inputs: Array<mixed> | void | null,
543 +): void {
544 + useEffectImpl(
545 + SnapshotEffect | UpdateEffect,
546 + UnmountSnapshot | MountMutation,
547 + create,
548 + inputs,
549 + );
550 +}
551 +
552 +export function useLayoutEffect(
553 + create: () => mixed,
554 + inputs: Array<mixed> | void | null,
555 +): void {
556 + useEffectImpl(UpdateEffect, UnmountMutation | MountLayout, create, inputs);
557 +}
558 +
559 export function useEffect(
560 create: () => mixed,
561 inputs: Array<mixed> | void | null,
562 ): void {
563 + useEffectImpl(
564 + UpdateEffect | PassiveEffect,
565 + UnmountPassive | MountPassive,
566 + create,
567 + inputs,
568 + );
569 +}
570 +
571 +function useEffectImpl(fiberEffectTag, hookEffectTag, create, inputs): void {
572 currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
573 workInProgressHook = createWorkInProgressHook();
574
@@ -512,19 +577,18 @@ export function useEffect(
577 if (currentHook !== null) {
578 const prevEffect = currentHook.memoizedState;
579 const prevInputs = prevEffect.inputs;
515 - if (inputsAreEqual(nextInputs, prevInputs)) {
516 - nextEffect = pushEffect(prevEffect.value, prevInputs);
517 - } else {
518 - nextEffect = pushEffect(create, nextInputs);
519 - }
580 + nextEffect = pushEffect(
581 + inputsAreEqual(nextInputs, prevInputs) ? NoHookEffect : hookEffectTag,
582 + create,
583 + prevEffect.destroy,
584 + nextInputs,
585 + );
586 } else {
521 - nextEffect = pushEffect(create, nextInputs);
587 + nextEffect = pushEffect(hookEffectTag, create, null, nextInputs);
588 }
589
524 - // TODO: If we decide not to support removing hooks from the end of the list,
525 - // we only need to schedule an effect if the inputs changed.
526 - currentlyRenderingFiber.effectTag |= CallbackEffect;
590 workInProgressHook.memoizedState = nextEffect;
591 + currentlyRenderingFiber.effectTag |= fiberEffectTag;
592 }
593
594 export function useAPI<T>(
@@ -541,21 +605,26 @@ export function useAPI<T>(
605 // TODO: I've implemented this on top of useEffect because it's almost the
606 // same thing, and it would require an equal amount of code. It doesn't seem
607 // like a common enough use case to justify the additional size.
544 - useEffect(() => {
545 - if (typeof ref === 'function') {
546 - const refCallback = ref;
547 - const inst = create();
548 - refCallback(inst);
549 - return () => refCallback(null);
550 - } else if (ref !== null && ref !== undefined) {
551 - const refObject = ref;
552 - const inst = create();
553 - refObject.current = inst;
554 - return () => {
555 - refObject.current = null;
556 - };
557 - }
558 - }, nextInputs);
608 + useEffectImpl(
609 + UpdateEffect,
610 + UnmountMutation | MountLayout,
611 + () => {
612 + if (typeof ref === 'function') {
613 + const refCallback = ref;
614 + const inst = create();
615 + refCallback(inst);
616 + return () => refCallback(null);
617 + } else if (ref !== null && ref !== undefined) {
618 + const refObject = ref;
619 + const inst = create();
620 + refObject.current = inst;
621 + return () => {
622 + refObject.current = null;
623 + };
624 + }
625 + },
626 + nextInputs,
627 + );
628 }
629
630 export function useCallback<T>(
packages/react-reconciler/src/ReactFiberRoot.js
+7
@@ -36,6 +36,9 @@ type BaseFiberRootProperties = {|
36 // The currently active root fiber. This is the mutable root of the tree.
37 current: Fiber,
38
39 + serialEffectCallback: (() => mixed) | null,
40 + serialEffectCallbackHandle: any,
41 +
42 // The following priority levels are used to distinguish between 1)
43 // uncommitted work, 2) uncommitted work that is suspended, and 3) uncommitted
44 // work that may be unsuspended. We choose not to track each individual
@@ -114,6 +117,8 @@ export function createFiberRoot(
117 current: uninitializedFiber,
118 containerInfo: containerInfo,
119 pendingChildren: null,
120 + serialEffectCallback: null,
121 + serialEffectCallbackHandle: null,
122
123 earliestPendingTime: NoWork,
124 latestPendingTime: NoWork,
@@ -143,6 +148,8 @@ export function createFiberRoot(
148 current: uninitializedFiber,
149 containerInfo: containerInfo,
150 pendingChildren: null,
151 + serialEffectCallback: null,
152 + serialEffectCallbackHandle: null,
153
154 earliestPendingTime: NoWork,
155 latestPendingTime: NoWork,
packages/react-reconciler/src/ReactFiberScheduler.js
+99 -17
@@ -12,7 +12,15 @@ import type {Batch, FiberRoot} from './ReactFiberRoot';
12 import type {ExpirationTime} from './ReactFiberExpirationTime';
13 import type {Interaction} from 'scheduler/src/Tracing';
14
15 -import {__interactionsRef, __subscriberRef} from 'scheduler/tracing';
15 +import {
16 + __interactionsRef,
17 + __subscriberRef,
18 + unstable_wrap as Schedule_tracing_wrap,
19 +} from 'scheduler/tracing';
20 +import {
21 + unstable_scheduleCallback as Schedule_scheduleCallback,
22 + unstable_cancelCallback as Schedule_cancelCallback,
23 +} from 'scheduler';
24 import {
25 invokeGuardedCallback,
26 hasCaughtError,
@@ -34,6 +42,7 @@ import {
42 Ref,
43 Incomplete,
44 HostEffectMask,
45 + Passive,
46 } from 'shared/ReactSideEffectTags';
47 import {
48 HostRoot,
@@ -151,6 +160,7 @@ import {
160 commitLifeCycles,
161 commitAttachRef,
162 commitDetachRef,
163 + commitPassiveHookEffects,
164 } from './ReactFiberCommitWork';
165 import {Dispatcher} from './ReactFiberDispatcher';
166
@@ -254,6 +264,7 @@ let nextRenderDidError: boolean = false;
264 let nextEffect: Fiber | null = null;
265
266 let isCommitting: boolean = false;
267 +let needsPassiveCommit: boolean = false;
268
269 let legacyErrorBoundariesThatAlreadyFailed: Set<mixed> | null = null;
270
@@ -453,8 +464,6 @@ function commitBeforeMutationLifecycles() {
464 commitBeforeMutationLifeCycles(current, nextEffect);
465 }
466
456 - // Don't cleanup effects yet;
457 - // This will be done by commitAllLifeCycles()
467 nextEffect = nextEffect.nextEffect;
468 }
469
@@ -494,15 +503,51 @@ function commitAllLifeCycles(
503 commitAttachRef(nextEffect);
504 }
505
497 - const next = nextEffect.nextEffect;
498 - // Ensure that we clean these up so that we don't accidentally keep them.
499 - // I'm not actually sure this matters because we can't reset firstEffect
500 - // and lastEffect since they're on every node, not just the effectful
501 - // ones. So we have to clean everything as we reuse nodes anyway.
502 - nextEffect.nextEffect = null;
503 - // Ensure that we reset the effectTag here so that we can rely on effect
504 - // tags to reason about the current life-cycle.
505 - nextEffect = next;
506 + if (effectTag & Passive) {
507 + needsPassiveCommit = true;
508 + }
509 +
510 + nextEffect = nextEffect.nextEffect;
511 + }
512 +}
513 +
514 +function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void {
515 + // Set this to true to prevent re-entrancy
516 + const previousIsRendering = isRendering;
517 + isRendering = true;
518 +
519 + let effect = firstEffect;
520 + do {
521 + if (effect.effectTag & Passive) {
522 + let didError = false;
523 + let error;
524 + if (__DEV__) {
525 + invokeGuardedCallback(null, commitPassiveHookEffects, null, effect);
526 + if (hasCaughtError()) {
527 + didError = true;
528 + error = clearCaughtError();
529 + }
530 + } else {
531 + try {
532 + commitPassiveHookEffects(effect);
533 + } catch (e) {
534 + didError = true;
535 + error = e;
536 + }
537 + }
538 + if (didError) {
539 + captureCommitPhaseError(effect, error);
540 + }
541 + }
542 + effect = effect.nextEffect;
543 + } while (effect !== null);
544 +
545 + isRendering = previousIsRendering;
546 +
547 + // Check if work was scheduled by one of the effects
548 + const rootExpirationTime = root.expirationTime;
549 + if (rootExpirationTime !== NoWork) {
550 + requestWork(root, rootExpirationTime);
551 }
552 }
553
@@ -522,6 +567,20 @@ function markLegacyErrorBoundaryAsFailed(instance: mixed) {
567 }
568
569 function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
570 + const existingSerialEffectCallback = root.serialEffectCallback;
571 + const existingSerialEffectCallbackHandle = root.serialEffectCallbackHandle;
572 + if (existingSerialEffectCallback !== null) {
573 + // A passive callback was scheduled during the previous commit, but it did
574 + // not get a chance to flush. Flush it now to ensure serial execution.
575 + // This should fire before any new mutations.
576 + root.serialEffectCallback = null;
577 + if (existingSerialEffectCallbackHandle !== null) {
578 + root.serialEffectCallbackHandle = null;
579 + Schedule_cancelCallback(existingSerialEffectCallbackHandle);
580 + }
581 + existingSerialEffectCallback();
582 + }
583 +
584 isWorking = true;
585 isCommitting = true;
586 startCommitTimer();
@@ -711,6 +770,34 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
770 }
771 }
772
773 + if (firstEffect !== null && needsPassiveCommit) {
774 + const resolvedFirstEffect = firstEffect;
775 + // This commit included a passive effect. These do not need to fire until
776 + // after the next paint. Schedule an callback to fire them in an async
777 + // event. To ensure serial execution, the callback will be flushed early if
778 + // we enter another commit phase before then.
779 + needsPassiveCommit = false;
780 + let serialEffectCallback;
781 + if (enableSchedulerTracing) {
782 + // TODO: Avoid this extra callback by mutating the tracing ref directly,
783 + // like we do at the beginning of commitRoot. I've opted not to do that
784 + // here because that code is still in flux.
785 + serialEffectCallback = Schedule_tracing_wrap(() => {
786 + root.serialEffectCallback = null;
787 + commitPassiveEffects(root, resolvedFirstEffect);
788 + });
789 + } else {
790 + serialEffectCallback = () => {
791 + root.serialEffectCallback = null;
792 + commitPassiveEffects(root, resolvedFirstEffect);
793 + };
794 + }
795 + root.serialEffectCallback = serialEffectCallback;
796 + root.serialEffectCallbackHandle = Schedule_scheduleCallback(
797 + serialEffectCallback,
798 + );
799 + }
800 +
801 isCommitting = false;
802 isWorking = false;
803 stopCommitLifeCyclesTimer();
@@ -1423,11 +1510,6 @@ function dispatch(
1510 value: mixed,
1511 expirationTime: ExpirationTime,
1512 ) {
1426 - invariant(
1427 - !isWorking || isCommitting,
1428 - 'dispatch: Cannot dispatch during the render phase.',
1429 - );
1430 -
1513 let fiber = sourceFiber.return;
1514 while (fiber !== null) {
1515 switch (fiber.tag) {
packages/react-reconciler/src/ReactHookEffectTags.js new
+19
@@ -0,0 +1,19 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export type HookEffectTag = number;
11 +
12 +export const NoEffect = /* */ 0b00000000;
13 +export const UnmountSnapshot = /* */ 0b00000010;
14 +export const UnmountMutation = /* */ 0b00000100;
15 +export const MountMutation = /* */ 0b00001000;
16 +export const UnmountLayout = /* */ 0b00010000;
17 +export const MountLayout = /* */ 0b00100000;
18 +export const MountPassive = /* */ 0b01000000;
19 +export const UnmountPassive = /* */ 0b10000000;
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+591 -52
@@ -18,15 +18,41 @@ let ReactNoop;
18 let useState;
19 let useReducer;
20 let useEffect;
21 +let useMutationEffect;
22 +let useLayoutEffect;
23 let useCallback;
24 let useMemo;
25 let useRef;
26 let useAPI;
27 let forwardRef;
28 +let flushPassiveEffects;
29
30 describe('ReactHooks', () => {
31 beforeEach(() => {
32 jest.resetModules();
33 +
34 + jest.mock('scheduler', () => {
35 + let scheduledCallbacks = new Map();
36 +
37 + flushPassiveEffects = () => {
38 + scheduledCallbacks.forEach(cb => {
39 + cb();
40 + });
41 + scheduledCallbacks = new Map();
42 + };
43 +
44 + return {
45 + unstable_scheduleCallback(callback) {
46 + const handle = {};
47 + scheduledCallbacks.set(handle, callback);
48 + return handle;
49 + },
50 + unstable_cancelCallback(handle) {
51 + scheduledCallbacks.delete(handle);
52 + },
53 + };
54 + });
55 +
56 ReactFeatureFlags = require('shared/ReactFeatureFlags');
57 ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
58 ReactFeatureFlags.enableHooks = true;
@@ -35,6 +61,8 @@ describe('ReactHooks', () => {
61 useState = React.useState;
62 useReducer = React.useReducer;
63 useEffect = React.useEffect;
64 + useMutationEffect = React.useMutationEffect;
65 + useLayoutEffect = React.useLayoutEffect;
66 useCallback = React.useCallback;
67 useMemo = React.useMemo;
68 useRef = React.useRef;
@@ -563,12 +591,170 @@ describe('ReactHooks', () => {
591 return <Text text={'Count: ' + props.count} />;
592 }
593 ReactNoop.render(<Counter count={0} />);
566 - expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did commit [0]']);
594 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
595 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
596 + flushPassiveEffects();
597 + expect(ReactNoop.clearYields()).toEqual(['Did commit [0]']);
598
599 ReactNoop.render(<Counter count={1} />);
570 - expect(ReactNoop.flush()).toEqual(['Count: 1', 'Did commit [1]']);
600 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
601 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
602 + // Effects are deferred until after the commit
603 + flushPassiveEffects();
604 + expect(ReactNoop.clearYields()).toEqual(['Did commit [1]']);
605 + });
606 +
607 + it(
608 + 'flushes effects serially by flushing old effects before flushing ' +
609 + "new ones, if they haven't already fired",
610 + () => {
611 + function getCommittedText() {
612 + const children = ReactNoop.getChildren();
613 + if (children === null) {
614 + return null;
615 + }
616 + return children[0].prop;
617 + }
618 +
619 + function Counter(props) {
620 + useEffect(() => {
621 + ReactNoop.yield(
622 + `Committed state when effect was fired: ${getCommittedText()}`,
623 + );
624 + });
625 + return <Text text={props.count} />;
626 + }
627 + ReactNoop.render(<Counter count={0} />);
628 + expect(ReactNoop.flush()).toEqual([0]);
629 + expect(ReactNoop.getChildren()).toEqual([span(0)]);
630 +
631 + // Before the effects have a chance to flush, schedule another update
632 + ReactNoop.render(<Counter count={1} />);
633 + expect(ReactNoop.flush()).toEqual([
634 + 1,
635 + // The previous effect flushes before the host mutations
636 + 'Committed state when effect was fired: 0',
637 + ]);
638 + expect(ReactNoop.getChildren()).toEqual([span(1)]);
639 +
640 + flushPassiveEffects();
641 + expect(ReactNoop.clearYields()).toEqual([
642 + 'Committed state when effect was fired: 1',
643 + ]);
644 + },
645 + );
646 +
647 + it('updates have async priority', () => {
648 + function Counter(props) {
649 + const [count, updateCount] = useState('(empty)');
650 + useEffect(
651 + () => {
652 + ReactNoop.yield(`Schedule update [${props.count}]`);
653 + updateCount(props.count);
654 + },
655 + [props.count],
656 + );
657 + return <Text text={'Count: ' + count} />;
658 + }
659 + ReactNoop.render(<Counter count={0} />);
660 + expect(ReactNoop.flush()).toEqual(['Count: (empty)']);
661 + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
662 + flushPassiveEffects();
663 + expect(ReactNoop.clearYields()).toEqual(['Schedule update [0]']);
664 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
665 +
666 + ReactNoop.render(<Counter count={1} />);
667 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
668 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
669 + flushPassiveEffects();
670 + expect(ReactNoop.clearYields()).toEqual(['Schedule update [1]']);
671 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
672 + });
673 +
674 + it('updates have async priority even if effects are flushed early', () => {
675 + function Counter(props) {
676 + const [count, updateCount] = useState('(empty)');
677 + useEffect(
678 + () => {
679 + ReactNoop.yield(`Schedule update [${props.count}]`);
680 + updateCount(props.count);
681 + },
682 + [props.count],
683 + );
684 + return <Text text={'Count: ' + count} />;
685 + }
686 + ReactNoop.render(<Counter count={0} />);
687 + expect(ReactNoop.flush()).toEqual(['Count: (empty)']);
688 + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
689 +
690 + // Rendering again should flush the previous commit's effects
691 + ReactNoop.render(<Counter count={1} />);
692 + ReactNoop.flushThrough([
693 + 'Count: (empty)',
694 + 'Schedule update [0]',
695 + 'Count: 0',
696 + ]);
697 + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
698 +
699 + expect(ReactNoop.flush()).toEqual(['Schedule update [1]', 'Count: 1']);
700 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
701 + });
702 +
703 + it(
704 + 'in sync mode, useEffect is deferred and updates finish synchronously ' +
705 + '(in a single batch)',
706 + () => {
707 + function Counter(props) {
708 + const [count, updateCount] = useState('(empty)');
709 + useEffect(
710 + () => {
711 + // Update multiple times. These should all be batched together in
712 + // a single render.
713 + updateCount(props.count);
714 + updateCount(props.count);
715 + updateCount(props.count);
716 + updateCount(props.count);
717 + updateCount(props.count);
718 + updateCount(props.count);
719 + },
720 + [props.count],
721 + );
722 + return <Text text={'Count: ' + count} />;
723 + }
724 + ReactNoop.renderLegacySyncRoot(<Counter count={0} />);
725 + // Even in sync mode, effects are deferred until after paint
726 + expect(ReactNoop.flush()).toEqual(['Count: (empty)']);
727 + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
728 + // Now fire the effects
729 + flushPassiveEffects();
730 + // There were multiple updates, but there should only be a
731 + // single render
732 + expect(ReactNoop.clearYields()).toEqual(['Count: 0']);
733 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
734 + },
735 + );
736 +
737 + it('flushSync is not allowed', () => {
738 + function Counter(props) {
739 + const [count, updateCount] = useState('(empty)');
740 + useEffect(
741 + () => {
742 + ReactNoop.yield(`Schedule update [${props.count}]`);
743 + ReactNoop.flushSync(() => {
744 + updateCount(props.count);
745 + });
746 + },
747 + [props.count],
748 + );
749 + return <Text text={'Count: ' + count} />;
750 + }
751 + ReactNoop.render(<Counter count={0} />);
752 + expect(ReactNoop.flush()).toEqual(['Count: (empty)']);
753 + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
754 +
755 + expect(() => {
756 + flushPassiveEffects();
757 + }).toThrow('flushSync was called from inside a lifecycle method');
758 });
759
760 it('unmounts previous effect', () => {
@@ -582,16 +768,19 @@ describe('ReactHooks', () => {
768 return <Text text={'Count: ' + props.count} />;
769 }
770 ReactNoop.render(<Counter count={0} />);
585 - expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']);
771 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
772 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
773 + flushPassiveEffects();
774 + expect(ReactNoop.clearYields()).toEqual(['Did create [0]']);
775
776 ReactNoop.render(<Counter count={1} />);
589 - expect(ReactNoop.flush()).toEqual([
590 - 'Count: 1',
777 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
778 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
779 + flushPassiveEffects();
780 + expect(ReactNoop.clearYields()).toEqual([
781 'Did destroy [0]',
782 'Did create [1]',
783 ]);
594 - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
784 });
785
786 it('unmounts on deletion', () => {
@@ -605,8 +794,10 @@ describe('ReactHooks', () => {
794 return <Text text={'Count: ' + props.count} />;
795 }
796 ReactNoop.render(<Counter count={0} />);
608 - expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']);
797 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
798 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
799 + flushPassiveEffects();
800 + expect(ReactNoop.clearYields()).toEqual(['Did create [0]']);
801
802 ReactNoop.render(null);
803 expect(ReactNoop.flush()).toEqual(['Did destroy [0]']);
@@ -625,8 +816,10 @@ describe('ReactHooks', () => {
816 return <Text text={'Count: ' + props.count} />;
817 }
818 ReactNoop.render(<Counter count={0} />);
628 - expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did mount']);
819 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
820 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
821 + flushPassiveEffects();
822 + expect(ReactNoop.clearYields()).toEqual(['Did mount']);
823
824 ReactNoop.render(<Counter count={1} />);
825 // No effect, because constructor was hoisted outside render
@@ -653,31 +846,37 @@ describe('ReactHooks', () => {
846 return <Text text={text} />;
847 }
848 ReactNoop.render(<Counter label="Count" count={0} />);
656 - expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [Count: 0]']);
849 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
850 + flushPassiveEffects();
851 + expect(ReactNoop.clearYields()).toEqual(['Did create [Count: 0]']);
852 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
853
854 ReactNoop.render(<Counter label="Count" count={1} />);
855 // Count changed
661 - expect(ReactNoop.flush()).toEqual([
662 - 'Count: 1',
856 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
857 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
858 + flushPassiveEffects();
859 + expect(ReactNoop.clearYields()).toEqual([
860 'Did destroy [Count: 0]',
861 'Did create [Count: 1]',
862 ]);
666 - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
863
864 ReactNoop.render(<Counter label="Count" count={1} />);
865 // Nothing changed, so no effect should have fired
866 expect(ReactNoop.flush()).toEqual(['Count: 1']);
867 + flushPassiveEffects();
868 + expect(ReactNoop.clearYields()).toEqual(null);
869 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
870
871 ReactNoop.render(<Counter label="Total" count={1} />);
872 // Label changed
675 - expect(ReactNoop.flush()).toEqual([
676 - 'Total: 1',
873 + expect(ReactNoop.flush()).toEqual(['Total: 1']);
874 + expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]);
875 + flushPassiveEffects();
876 + expect(ReactNoop.clearYields()).toEqual([
877 'Did destroy [Count: 1]',
878 'Did create [Total: 1]',
879 ]);
680 - expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]);
880 });
881
882 it('multiple effects', () => {
@@ -691,20 +890,22 @@ describe('ReactHooks', () => {
890 return <Text text={'Count: ' + props.count} />;
891 }
892 ReactNoop.render(<Counter count={0} />);
694 - expect(ReactNoop.flush()).toEqual([
695 - 'Count: 0',
893 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
894 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
895 + flushPassiveEffects();
896 + expect(ReactNoop.clearYields()).toEqual([
897 'Did commit 1 [0]',
898 'Did commit 2 [0]',
899 ]);
699 - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
900
901 ReactNoop.render(<Counter count={1} />);
702 - expect(ReactNoop.flush()).toEqual([
703 - 'Count: 1',
902 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
903 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
904 + flushPassiveEffects();
905 + expect(ReactNoop.clearYields()).toEqual([
906 'Did commit 1 [1]',
907 'Did commit 2 [1]',
908 ]);
707 - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
909 });
910
911 it('unmounts all previous effects before creating any new ones', () => {
@@ -724,22 +925,371 @@ describe('ReactHooks', () => {
925 return <Text text={'Count: ' + props.count} />;
926 }
927 ReactNoop.render(<Counter count={0} />);
727 - expect(ReactNoop.flush()).toEqual([
728 - 'Count: 0',
928 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
929 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
930 + flushPassiveEffects();
931 + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']);
932 +
933 + ReactNoop.render(<Counter count={1} />);
934 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
935 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
936 + flushPassiveEffects();
937 + expect(ReactNoop.clearYields()).toEqual([
938 + 'Unmount A [0]',
939 + 'Unmount B [0]',
940 + 'Mount A [1]',
941 + 'Mount B [1]',
942 + ]);
943 + });
944 +
945 + it('handles errors on mount', () => {
946 + function Counter(props) {
947 + useEffect(() => {
948 + ReactNoop.yield(`Mount A [${props.count}]`);
949 + return () => {
950 + ReactNoop.yield(`Unmount A [${props.count}]`);
951 + };
952 + });
953 + useEffect(() => {
954 + ReactNoop.yield('Oops!');
955 + throw new Error('Oops!');
956 + // eslint-disable-next-line no-unreachable
957 + ReactNoop.yield(`Mount B [${props.count}]`);
958 + return () => {
959 + ReactNoop.yield(`Unmount B [${props.count}]`);
960 + };
961 + });
962 + return <Text text={'Count: ' + props.count} />;
963 + }
964 + ReactNoop.render(<Counter count={0} />);
965 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
966 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
967 + expect(() => flushPassiveEffects()).toThrow('Oops');
968 + expect(ReactNoop.clearYields()).toEqual([
969 'Mount A [0]',
730 - 'Mount B [0]',
970 + 'Oops!',
971 + // Clean up effect A. There's no effect B to clean-up, because it
972 + // never mounted.
973 + 'Unmount A [0]',
974 ]);
975 + expect(ReactNoop.getChildren()).toEqual([]);
976 + });
977 +
978 + it('handles errors on update', () => {
979 + function Counter(props) {
980 + useEffect(() => {
981 + ReactNoop.yield(`Mount A [${props.count}]`);
982 + return () => {
983 + ReactNoop.yield(`Unmount A [${props.count}]`);
984 + };
985 + });
986 + useEffect(() => {
987 + if (props.count === 1) {
988 + ReactNoop.yield('Oops!');
989 + throw new Error('Oops!');
990 + }
991 + ReactNoop.yield(`Mount B [${props.count}]`);
992 + return () => {
993 + ReactNoop.yield(`Unmount B [${props.count}]`);
994 + };
995 + });
996 + return <Text text={'Count: ' + props.count} />;
997 + }
998 + ReactNoop.render(<Counter count={0} />);
999 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
1000 expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
1001 + flushPassiveEffects();
1002 + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']);
1003
1004 + // This update will trigger an errror
1005 ReactNoop.render(<Counter count={1} />);
735 - expect(ReactNoop.flush()).toEqual([
736 - 'Count: 1',
1006 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
1007 + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
1008 + expect(() => flushPassiveEffects()).toThrow('Oops');
1009 + expect(ReactNoop.clearYields()).toEqual([
1010 'Unmount A [0]',
1011 'Unmount B [0]',
1012 'Mount A [1]',
740 - 'Mount B [1]',
1013 + 'Oops!',
1014 + // Clean up effect A. There's no effect B to clean-up, because it
1015 + // never mounted.
1016 + 'Unmount A [1]',
1017 ]);
1018 + expect(ReactNoop.getChildren()).toEqual([]);
1019 + });
1020 +
1021 + it('handles errors on unmount', () => {
1022 + function Counter(props) {
1023 + useEffect(() => {
1024 + ReactNoop.yield(`Mount A [${props.count}]`);
1025 + return () => {
1026 + ReactNoop.yield('Oops!');
1027 + throw new Error('Oops!');
1028 + // eslint-disable-next-line no-unreachable
1029 + ReactNoop.yield(`Unmount A [${props.count}]`);
1030 + };
1031 + });
1032 + useEffect(() => {
1033 + ReactNoop.yield(`Mount B [${props.count}]`);
1034 + return () => {
1035 + ReactNoop.yield(`Unmount B [${props.count}]`);
1036 + };
1037 + });
1038 + return <Text text={'Count: ' + props.count} />;
1039 + }
1040 + ReactNoop.render(<Counter count={0} />);
1041 + expect(ReactNoop.flush()).toEqual(['Count: 0']);
1042 + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
1043 + flushPassiveEffects();
1044 + expect(ReactNoop.clearYields()).toEqual(['Mount A [0]', 'Mount B [0]']);
1045 +
1046 + // This update will trigger an errror
1047 + ReactNoop.render(<Counter count={1} />);
1048 + expect(ReactNoop.flush()).toEqual(['Count: 1']);
1049 expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
1050 + expect(() => flushPassiveEffects()).toThrow('Oops');
1051 + expect(ReactNoop.clearYields()).toEqual([
1052 + 'Oops!',
1053 + // B unmounts even though an error was thrown in the previous effect
1054 + 'Unmount B [0]',
1055 + ]);
1056 + expect(ReactNoop.getChildren()).toEqual([]);
1057 + });
1058 + });
1059 +
1060 + describe('useMutationEffect and useLayoutEffect', () => {
1061 + it('fires layout effects after the host has been mutated', () => {
1062 + function getCommittedText() {
1063 + const children = ReactNoop.getChildren();
1064 + if (children === null) {
1065 + return null;
1066 + }
1067 + return children[0].prop;
1068 + }
1069 +
1070 + function Counter(props) {
1071 + useLayoutEffect(() => {
1072 + ReactNoop.yield(`Current: ${getCommittedText()}`);
1073 + });
1074 + return <Text text={props.count} />;
1075 + }
1076 +
1077 + ReactNoop.render(<Counter count={0} />);
1078 + expect(ReactNoop.flush()).toEqual([0, 'Current: 0']);
1079 + expect(ReactNoop.getChildren()).toEqual([span(0)]);
1080 +
1081 + ReactNoop.render(<Counter count={1} />);
1082 + expect(ReactNoop.flush()).toEqual([1, 'Current: 1']);
1083 + expect(ReactNoop.getChildren()).toEqual([span(1)]);
1084 + });
1085 +
1086 + it('fires mutation effects before layout effects', () => {
1087 + let committedText = '(empty)';
1088 +
1089 + function Counter(props) {
1090 + useMutationEffect(() => {
1091 + ReactNoop.yield(`Mount mutation [current: ${committedText}]`);
1092 + committedText = props.count + '';
1093 + return () => {
1094 + ReactNoop.yield(`Unmount mutation [current: ${committedText}]`);
1095 + };
1096 + });
1097 + useLayoutEffect(() => {
1098 + ReactNoop.yield(`Mount layout [current: ${committedText}]`);
1099 + return () => {
1100 + ReactNoop.yield(`Unmount layout [current: ${committedText}]`);
1101 + };
1102 + });
1103 + useEffect(() => {
1104 + ReactNoop.yield(`Mount normal [current: ${committedText}]`);
1105 + return () => {
1106 + ReactNoop.yield(`Unmount normal [current: ${committedText}]`);
1107 + };
1108 + });
1109 + return null;
1110 + }
1111 +
1112 + ReactNoop.render(<Counter count={0} />);
1113 + expect(ReactNoop.flush()).toEqual([
1114 + 'Mount mutation [current: (empty)]',
1115 + 'Mount layout [current: 0]',
1116 + ]);
1117 + expect(committedText).toEqual('0');
1118 + flushPassiveEffects();
1119 + expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 0]']);
1120 +
1121 + // Unmount everything
1122 + ReactNoop.render(null);
1123 + expect(ReactNoop.flush()).toEqual([
1124 + 'Unmount mutation [current: 0]',
1125 + 'Unmount layout [current: 0]',
1126 + 'Unmount normal [current: 0]',
1127 + ]);
1128 + });
1129 +
1130 + it('force flushes passive effects before firing new mutation effects', () => {
1131 + let committedText = '(empty)';
1132 +
1133 + function Counter(props) {
1134 + useMutationEffect(() => {
1135 + ReactNoop.yield(`Mount mutation [current: ${committedText}]`);
1136 + committedText = props.count + '';
1137 + return () => {
1138 + ReactNoop.yield(`Unmount mutation [current: ${committedText}]`);
1139 + };
1140 + });
1141 + useEffect(() => {
1142 + ReactNoop.yield(`Mount normal [current: ${committedText}]`);
1143 + return () => {
1144 + ReactNoop.yield(`Unmount normal [current: ${committedText}]`);
1145 + };
1146 + });
1147 + return null;
1148 + }
1149 +
1150 + ReactNoop.render(<Counter count={0} />);
1151 + expect(ReactNoop.flush()).toEqual(['Mount mutation [current: (empty)]']);
1152 + expect(committedText).toEqual('0');
1153 +
1154 + ReactNoop.render(<Counter count={1} />);
1155 + expect(ReactNoop.flush()).toEqual([
1156 + 'Mount normal [current: 0]',
1157 + 'Unmount mutation [current: 0]',
1158 + 'Mount mutation [current: 0]',
1159 + ]);
1160 + expect(committedText).toEqual('1');
1161 +
1162 + flushPassiveEffects();
1163 + expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 1]']);
1164 + });
1165 +
1166 + it('force flushes passive effects before firing new layout effects', () => {
1167 + let committedText = '(empty)';
1168 +
1169 + function Counter(props) {
1170 + useLayoutEffect(() => {
1171 + // Normally this would go in a mutation effect, but this test
1172 + // intentionally omits a mutation effect.
1173 + committedText = props.count + '';
1174 +
1175 + ReactNoop.yield(`Mount layout [current: ${committedText}]`);
1176 + return () => {
1177 + ReactNoop.yield(`Unmount layout [current: ${committedText}]`);
1178 + };
1179 + });
1180 + useEffect(() => {
1181 + ReactNoop.yield(`Mount normal [current: ${committedText}]`);
1182 + return () => {
1183 + ReactNoop.yield(`Unmount normal [current: ${committedText}]`);
1184 + };
1185 + });
1186 + return null;
1187 + }
1188 +
1189 + ReactNoop.render(<Counter count={0} />);
1190 + expect(ReactNoop.flush()).toEqual(['Mount layout [current: 0]']);
1191 + expect(committedText).toEqual('0');
1192 +
1193 + ReactNoop.render(<Counter count={1} />);
1194 + expect(ReactNoop.flush()).toEqual([
1195 + 'Mount normal [current: 0]',
1196 + 'Unmount layout [current: 0]',
1197 + 'Mount layout [current: 1]',
1198 + ]);
1199 + expect(committedText).toEqual('1');
1200 +
1201 + flushPassiveEffects();
1202 + expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 1]']);
1203 + });
1204 +
1205 + it('fires all mutation effects before firing any layout effects', () => {
1206 + let committedA = '(empty)';
1207 + let committedB = '(empty)';
1208 +
1209 + function CounterA(props) {
1210 + useMutationEffect(() => {
1211 + ReactNoop.yield(
1212 + `Mount A mutation [A: ${committedA}, B: ${committedB}]`,
1213 + );
1214 + committedA = props.count + '';
1215 + return () => {
1216 + ReactNoop.yield(
1217 + `Unmount A mutation [A: ${committedA}, B: ${committedB}]`,
1218 + );
1219 + };
1220 + });
1221 + useLayoutEffect(() => {
1222 + ReactNoop.yield(
1223 + `Mount layout A [A: ${committedA}, B: ${committedB}]`,
1224 + );
1225 + return () => {
1226 + ReactNoop.yield(
1227 + `Unmount layout A [A: ${committedA}, B: ${committedB}]`,
1228 + );
1229 + };
1230 + });
1231 + return null;
1232 + }
1233 +
1234 + function CounterB(props) {
1235 + useMutationEffect(() => {
1236 + ReactNoop.yield(
1237 + `Mount B mutation [A: ${committedA}, B: ${committedB}]`,
1238 + );
1239 + committedB = props.count + '';
1240 + return () => {
1241 + ReactNoop.yield(
1242 + `Unmount B mutation [A: ${committedA}, B: ${committedB}]`,
1243 + );
1244 + };
1245 + });
1246 + useLayoutEffect(() => {
1247 + ReactNoop.yield(
1248 + `Mount layout B [A: ${committedA}, B: ${committedB}]`,
1249 + );
1250 + return () => {
1251 + ReactNoop.yield(
1252 + `Unmount layout B [A: ${committedA}, B: ${committedB}]`,
1253 + );
1254 + };
1255 + });
1256 + return null;
1257 + }
1258 +
1259 + ReactNoop.render(
1260 + <React.Fragment>
1261 + <CounterA count={0} />
1262 + <CounterB count={0} />
1263 + </React.Fragment>,
1264 + );
1265 + expect(ReactNoop.flush()).toEqual([
1266 + // All mutation effects fire before all layout effects
1267 + 'Mount A mutation [A: (empty), B: (empty)]',
1268 + 'Mount B mutation [A: 0, B: (empty)]',
1269 + 'Mount layout A [A: 0, B: 0]',
1270 + 'Mount layout B [A: 0, B: 0]',
1271 + ]);
1272 + expect([committedA, committedB]).toEqual(['0', '0']);
1273 +
1274 + ReactNoop.render(
1275 + <React.Fragment>
1276 + <CounterA count={1} />
1277 + <CounterB count={1} />
1278 + </React.Fragment>,
1279 + );
1280 + expect(ReactNoop.flush()).toEqual([
1281 + // Note: This shows that the clean-up function of a layout effect is
1282 + // fired in the same phase as the set-up function of a mutation.
1283 + 'Unmount A mutation [A: 0, B: 0]',
1284 + 'Unmount B mutation [A: 0, B: 0]',
1285 + 'Mount A mutation [A: 0, B: 0]',
1286 + 'Unmount layout A [A: 1, B: 0]',
1287 + 'Mount B mutation [A: 1, B: 0]',
1288 + 'Unmount layout B [A: 1, B: 1]',
1289 + 'Mount layout A [A: 1, B: 1]',
1290 + 'Mount layout B [A: 1, B: 1]',
1291 + ]);
1292 + expect([committedA, committedB]).toEqual(['1', '1']);
1293 });
1294 });
1295
@@ -1006,26 +1556,11 @@ describe('ReactHooks', () => {
1556 updateC(4);
1557 expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
1558 expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
1009 -
1559 ReactNoop.render(<App loadC={false} />);
1011 - expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
1012 - expect(ReactNoop.getChildren()).toEqual([
1013 - span('A: 2, B: 3, C: [not loaded]'),
1014 - ]);
1015 -
1016 - updateC(4);
1017 - // TODO: This hook triggered a re-render even though it's unmounted.
1018 - // Should we warn?
1019 - expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
1020 - expect(ReactNoop.getChildren()).toEqual([
1021 - span('A: 2, B: 3, C: [not loaded]'),
1022 - ]);
1023 -
1024 - updateB(4);
1025 - expect(ReactNoop.flush()).toEqual(['A: 2, B: 4, C: [not loaded]']);
1026 - expect(ReactNoop.getChildren()).toEqual([
1027 - span('A: 2, B: 4, C: [not loaded]'),
1028 - ]);
1560 + expect(() => ReactNoop.flush()).toThrow(
1561 + 'Rendered fewer hooks than expected. This may be caused by an ' +
1562 + 'accidental early return statement.',
1563 + );
1564 });
1565
1566 it('unmount effects', () => {
@@ -1050,16 +1585,20 @@ describe('ReactHooks', () => {
1585 }
1586
1587 ReactNoop.render(<App showMore={false} />);
1053 - expect(ReactNoop.flush()).toEqual(['Mount A']);
1588 + expect(ReactNoop.flush()).toEqual([]);
1589 + flushPassiveEffects();
1590 + expect(ReactNoop.clearYields()).toEqual(['Mount A']);
1591
1592 ReactNoop.render(<App showMore={true} />);
1056 - expect(ReactNoop.flush()).toEqual(['Mount B']);
1593 + expect(ReactNoop.flush()).toEqual([]);
1594 + flushPassiveEffects();
1595 + expect(ReactNoop.clearYields()).toEqual(['Mount B']);
1596
1597 ReactNoop.render(<App showMore={false} />);
1059 - expect(ReactNoop.flush()).toEqual(['Unmount B']);
1060 -
1061 - ReactNoop.render(null);
1062 - expect(ReactNoop.flush()).toEqual(['Unmount A']);
1598 + expect(() => ReactNoop.flush()).toThrow(
1599 + 'Rendered fewer hooks than expected. This may be caused by an ' +
1600 + 'accidental early return statement.',
1601 + );
1602 });
1603 });
1604 });
packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js
+23 -3
@@ -37,6 +37,13 @@ describe('ReactNewContext', () => {
37 return {type: 'span', children: [], prop, hidden: false};
38 }
39
40 + function readContext(Context, observedBits) {
41 + const dispatcher =
42 + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner
43 + .currentDispatcher;
44 + return dispatcher.readContext(Context, observedBits);
45 + }
46 +
47 // We have several ways of reading from context. sharedContextTests runs
48 // a suite of tests for a given context consumer implementation.
49 sharedContextTests('Context.Consumer', Context => Context.Consumer);
@@ -51,12 +58,12 @@ describe('ReactNewContext', () => {
58 },
59 );
60 sharedContextTests(
54 - 'useContext inside class component',
61 + 'readContext(Context) inside class component',
62 Context =>
63 class Consumer extends React.Component {
64 render() {
65 const observedBits = this.props.unstable_observedBits;
59 - const contextValue = useContext(Context, observedBits);
66 + const contextValue = readContext(Context, observedBits);
67 const render = this.props.children;
68 return render(contextValue);
69 }
@@ -1190,7 +1197,7 @@ describe('ReactNewContext', () => {
1197 return (
1198 <FooContext.Consumer>
1199 {foo => {
1193 - const bar = useContext(BarContext);
1200 + const bar = readContext(BarContext);
1201 return <Text text={`Foo: ${foo}, Bar: ${bar}`} />;
1202 }}
1203 </FooContext.Consumer>
@@ -1325,6 +1332,19 @@ describe('ReactNewContext', () => {
1332 span('Baz: 2'),
1333 ]);
1334 });
1335 +
1336 + it('throws when used in a class component', () => {
1337 + const Context = React.createContext(0);
1338 + class Foo extends React.Component {
1339 + render() {
1340 + return useContext(Context);
1341 + }
1342 + }
1343 + ReactNoop.render(<Foo />);
1344 + expect(ReactNoop.flush).toThrow(
1345 + 'Hooks can only be called inside the body of a functional component.',
1346 + );
1347 + });
1348 });
1349
1350 it('unwinds after errors in complete phase', () => {
packages/react/src/React.js
+14 -10
@@ -29,14 +29,16 @@ import {lazy} from './ReactLazy';
29 import forwardRef from './forwardRef';
30 import memo from './memo';
31 import {
32 + useAPI,
33 + useCallback,
34 useContext,
33 - useState,
34 - useReducer,
35 - useRef,
35 useEffect,
37 - useCallback,
36 + useLayoutEffect,
37 useMemo,
39 - useAPI,
38 + useMutationEffect,
39 + useReducer,
40 + useRef,
41 + useState,
42 } from './ReactHooks';
43 import {
44 createElementWithValidation,
@@ -87,14 +89,16 @@ if (enableStableConcurrentModeAPIs) {
89 }
90
91 if (enableHooks) {
92 + React.useAPI = useAPI;
93 + React.useCallback = useCallback;
94 React.useContext = useContext;
91 - React.useState = useState;
92 - React.useReducer = useReducer;
93 - React.useRef = useRef;
95 React.useEffect = useEffect;
95 - React.useCallback = useCallback;
96 + React.useLayoutEffect = useLayoutEffect;
97 React.useMemo = useMemo;
97 - React.useAPI = useAPI;
98 + React.useMutationEffect = useMutationEffect;
99 + React.useReducer = useReducer;
100 + React.useRef = useRef;
101 + React.useState = useState;
102 }
103
104 export default React;
packages/react/src/ReactHooks.js
+17 -2
@@ -8,7 +8,6 @@
8 */
9
10 import type {ReactContext} from 'shared/ReactTypes';
11 -
11 import invariant from 'shared/invariant';
12
13 import ReactCurrentOwner from './ReactCurrentOwner';
@@ -27,7 +26,7 @@ export function useContext<T>(
26 observedBits: number | boolean | void,
27 ) {
28 const dispatcher = resolveDispatcher();
30 - return dispatcher.readContext(Context, observedBits);
29 + return dispatcher.useContext(Context, observedBits);
30 }
31
32 export function useState<S>(initialState: S | (() => S)) {
@@ -57,6 +56,22 @@ export function useEffect(
56 return dispatcher.useEffect(create, inputs);
57 }
58
59 +export function useMutationEffect(
60 + create: () => mixed,
61 + inputs: Array<mixed> | void | null,
62 +) {
63 + const dispatcher = resolveDispatcher();
64 + return dispatcher.useMutationEffect(create, inputs);
65 +}
66 +
67 +export function useLayoutEffect(
68 + create: () => mixed,
69 + inputs: Array<mixed> | void | null,
70 +) {
71 + const dispatcher = resolveDispatcher();
72 + return dispatcher.useLayoutEffect(create, inputs);
73 +}
74 +
75 export function useCallback(
76 callback: () => mixed,
77 inputs: Array<mixed> | void | null,
packages/shared/ReactSideEffectTags.js
+17 -16
@@ -10,25 +10,26 @@
10 export type SideEffectTag = number;
11
12 // Don't change these two values. They're used by React Dev Tools.
13 -export const NoEffect = /* */ 0b00000000000;
14 -export const PerformedWork = /* */ 0b00000000001;
13 +export const NoEffect = /* */ 0b000000000000;
14 +export const PerformedWork = /* */ 0b000000000001;
15
16 // You can change the rest (and add more).
17 -export const Placement = /* */ 0b00000000010;
18 -export const Update = /* */ 0b00000000100;
19 -export const PlacementAndUpdate = /* */ 0b00000000110;
20 -export const Deletion = /* */ 0b00000001000;
21 -export const ContentReset = /* */ 0b00000010000;
22 -export const Callback = /* */ 0b00000100000;
23 -export const DidCapture = /* */ 0b00001000000;
24 -export const Ref = /* */ 0b00010000000;
25 -export const Snapshot = /* */ 0b00100000000;
17 +export const Placement = /* */ 0b000000000010;
18 +export const Update = /* */ 0b000000000100;
19 +export const PlacementAndUpdate = /* */ 0b000000000110;
20 +export const Deletion = /* */ 0b000000001000;
21 +export const ContentReset = /* */ 0b000000010000;
22 +export const Callback = /* */ 0b000000100000;
23 +export const DidCapture = /* */ 0b000001000000;
24 +export const Ref = /* */ 0b000010000000;
25 +export const Snapshot = /* */ 0b000100000000;
26 +export const Passive = /* */ 0b001000000000;
27
27 -// Update & Callback & Ref & Snapshot
28 -export const LifecycleEffectMask = /* */ 0b00110100100;
28 +// Passive & Update & Callback & Ref & Snapshot
29 +export const LifecycleEffectMask = /* */ 0b001110100100;
30
31 // Union of all host effects
31 -export const HostEffectMask = /* */ 0b00111111111;
32 +export const HostEffectMask = /* */ 0b001111111111;
33
33 -export const Incomplete = /* */ 0b01000000000;
34 -export const ShouldCapture = /* */ 0b10000000000;
34 +export const Incomplete = /* */ 0b010000000000;
35 +export const ShouldCapture = /* */ 0b100000000000;