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;