Make sure deletions don't stop passive effects
Before the fix, the passive effect in the test is never executed. We were previously waiting until the next commit phase to run effects. Now, we run them before scheduling work.
Sophie Alpert committed
Oct 3, 2018 at 18:31 UTC
6514697f0cb294b48550eeb3bcbddfd11143539c
7 files changed
+140
-54
packages/react-reconciler/src/ReactFiberClassComponent.js
+4
@@ -51,6 +51,7 @@ import {
51
requestCurrentTime,
52
computeExpirationForFiber,
53
scheduleWork,
54
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber,
55
} from './ReactFiberScheduler';
56
57
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -199,6 +200,7 @@ const classComponentUpdater = {
200
update.callback = callback;
201
}
202
203
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(fiber);
204
enqueueUpdate(fiber, update);
205
scheduleWork(fiber, expirationTime);
206
},
@@ -218,6 +220,7 @@ const classComponentUpdater = {
220
update.callback = callback;
221
}
222
223
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(fiber);
224
enqueueUpdate(fiber, update);
225
scheduleWork(fiber, expirationTime);
226
},
@@ -236,6 +239,7 @@ const classComponentUpdater = {
239
update.callback = callback;
240
}
241
242
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(fiber);
243
enqueueUpdate(fiber, update);
244
scheduleWork(fiber, expirationTime);
245
},
packages/react-reconciler/src/ReactFiberCommitWork.js
+2
@@ -83,6 +83,7 @@ import {
83
} from './ReactFiberHostConfig';
84
import {
85
captureCommitPhaseError,
86
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber,
87
requestCurrentTime,
88
scheduleWork,
89
} from './ReactFiberScheduler';
@@ -452,6 +453,7 @@ function commitLifeCycles(
453
timedOutAt: NoWork,
454
};
455
finishedWork.memoizedState = newState;
456
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(finishedWork);
457
scheduleWork(finishedWork, Sync);
458
return;
459
}
packages/react-reconciler/src/ReactFiberHooks.js
+2
@@ -32,6 +32,7 @@ import {
32
import {
33
scheduleWork,
34
computeExpirationForFiber,
35
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber,
36
requestCurrentTime,
37
} from './ReactFiberScheduler';
38
@@ -724,6 +725,7 @@ function dispatchAction<S, A>(
725
callback: callback !== undefined ? callback : null,
726
next: null,
727
};
728
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(fiber);
729
// Append the update to the end of the list.
730
const last = queue.last;
731
if (last === null) {
packages/react-reconciler/src/ReactFiberReconciler.js
+4
-1
@@ -52,6 +52,7 @@ import {
52
syncUpdates,
53
interactiveUpdates,
54
flushInteractiveUpdates,
55
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber,
56
} from './ReactFiberScheduler';
57
import {createUpdate, enqueueUpdate} from './ReactUpdateQueue';
58
import ReactFiberInstrumentation from './ReactFiberInstrumentation';
@@ -145,9 +146,11 @@ function scheduleRootUpdate(
146
);
147
update.callback = callback;
148
}
148
- enqueueUpdate(current, update);
149
150
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber(current);
151
+ enqueueUpdate(current, update);
152
scheduleWork(current, expirationTime);
153
+
154
return expirationTime;
155
}
156
packages/react-reconciler/src/ReactFiberRoot.js
-7
@@ -36,9 +36,6 @@ 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
-
39
// The following priority levels are used to distinguish between 1)
40
// uncommitted work, 2) uncommitted work that is suspended, and 3) uncommitted
41
// work that may be unsuspended. We choose not to track each individual
@@ -117,8 +114,6 @@ export function createFiberRoot(
114
current: uninitializedFiber,
115
containerInfo: containerInfo,
116
pendingChildren: null,
120
- serialEffectCallback: null,
121
- serialEffectCallbackHandle: null,
117
118
earliestPendingTime: NoWork,
119
latestPendingTime: NoWork,
@@ -148,8 +143,6 @@ export function createFiberRoot(
143
current: uninitializedFiber,
144
containerInfo: containerInfo,
145
pendingChildren: null,
151
- serialEffectCallback: null,
152
- serialEffectCallbackHandle: null,
146
147
earliestPendingTime: NoWork,
148
latestPendingTime: NoWork,
packages/react-reconciler/src/ReactFiberScheduler.js
+59
-37
@@ -264,7 +264,9 @@ let nextRenderDidError: boolean = false;
264
let nextEffect: Fiber | null = null;
265
266
let isCommitting: boolean = false;
267
-let needsPassiveCommit: boolean = false;
267
+let rootWithPendingPassiveEffects: FiberRoot | null = null;
268
+let firstPassiveEffect: Fiber | null = null;
269
+let passiveEffectCallbackHandle: * = null;
270
271
let legacyErrorBoundariesThatAlreadyFailed: Set<mixed> | null = null;
272
@@ -504,7 +506,7 @@ function commitAllLifeCycles(
506
}
507
508
if (effectTag & Passive) {
507
- needsPassiveCommit = true;
509
+ rootWithPendingPassiveEffects = finishedRoot;
510
}
511
512
nextEffect = nextEffect.nextEffect;
@@ -512,6 +514,9 @@ function commitAllLifeCycles(
514
}
515
516
function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void {
517
+ rootWithPendingPassiveEffects = null;
518
+ passiveEffectCallbackHandle = null;
519
+
520
// Set this to true to prevent re-entrancy
521
const previousIsRendering = isRendering;
522
isRendering = true;
@@ -566,21 +571,42 @@ function markLegacyErrorBoundaryAsFailed(instance: mixed) {
571
}
572
}
573
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);
574
+function flushPassiveEffectsBeforeSchedulingUpdateOnFiber(fiber: Fiber) {
575
+ if (rootWithPendingPassiveEffects !== null) {
576
+ // TODO: This is an unfortunate extra loop. We end up traversing to the root
577
+ // again in scheduleWorkToRoot. But we have to do this one first because it
578
+ // needs to happen before adding an update to the queue, and
579
+ // scheduleWorkToRoot may perform a synchronous re-render. Maybe we can
580
+ // solve this with batchedUpdates, or with the equivalent in the Scheduler
581
+ // package.
582
+ let node = fiber;
583
+ do {
584
+ switch (node.tag) {
585
+ case HostRoot: {
586
+ const root: FiberRoot = node.stateNode;
587
+ flushPassiveEffects(root);
588
+ return;
589
+ }
590
+ }
591
+ node = node.return;
592
+ } while (node !== null);
593
+ }
594
+}
595
+
596
+function flushPassiveEffects(root: FiberRoot) {
597
+ if (root === rootWithPendingPassiveEffects) {
598
+ rootWithPendingPassiveEffects = null;
599
+ if (passiveEffectCallbackHandle !== null) {
600
+ Schedule_cancelCallback(passiveEffectCallbackHandle);
601
+ passiveEffectCallbackHandle = null;
602
+ }
603
+ if (firstPassiveEffect !== null) {
604
+ commitPassiveEffects(root, firstPassiveEffect);
605
}
581
- existingSerialEffectCallback();
606
}
607
+}
608
609
+function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
610
isWorking = true;
611
isCommitting = true;
612
startCommitTimer();
@@ -770,31 +796,30 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void {
796
}
797
}
798
773
- if (firstEffect !== null && needsPassiveCommit) {
774
- const resolvedFirstEffect = firstEffect;
799
+ if (firstEffect !== null && rootWithPendingPassiveEffects !== null) {
800
// This commit included a passive effect. These do not need to fire until
801
// after the next paint. Schedule an callback to fire them in an async
802
// 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;
803
+ // we enter rootWithPendingPassiveEffects commit phase before then.
804
+ const resolvedFirstEffect = (firstPassiveEffect = firstEffect);
805
+
806
+ let passiveEffectCallback;
807
if (enableSchedulerTracing) {
808
// TODO: Avoid this extra callback by mutating the tracing ref directly,
809
// like we do at the beginning of commitRoot. I've opted not to do that
810
// here because that code is still in flux.
785
- serialEffectCallback = Schedule_tracing_wrap(() => {
786
- root.serialEffectCallback = null;
811
+ passiveEffectCallback = Schedule_tracing_wrap(() => {
812
commitPassiveEffects(root, resolvedFirstEffect);
813
});
814
} else {
790
- serialEffectCallback = () => {
791
- root.serialEffectCallback = null;
792
- commitPassiveEffects(root, resolvedFirstEffect);
793
- };
815
+ passiveEffectCallback = commitPassiveEffects.bind(
816
+ null,
817
+ root,
818
+ resolvedFirstEffect,
819
+ );
820
}
795
- root.serialEffectCallback = serialEffectCallback;
796
- root.serialEffectCallbackHandle = Schedule_scheduleCallback(
797
- serialEffectCallback,
821
+ passiveEffectCallbackHandle = Schedule_scheduleCallback(
822
+ passiveEffectCallback,
823
);
824
}
825
@@ -1227,6 +1252,9 @@ function renderRoot(
1252
'renderRoot was called recursively. This error is likely caused ' +
1253
'by a bug in React. Please file an issue.',
1254
);
1255
+
1256
+ flushPassiveEffects(root);
1257
+
1258
isWorking = true;
1259
ReactCurrentOwner.currentDispatcher = Dispatcher;
1260
@@ -1505,11 +1533,8 @@ function renderRoot(
1533
onComplete(root, rootWorkInProgress, expirationTime);
1534
}
1535
1508
-function dispatch(
1509
- sourceFiber: Fiber,
1510
- value: mixed,
1511
- expirationTime: ExpirationTime,
1512
-) {
1536
+function captureCommitPhaseError(sourceFiber: Fiber, value: mixed) {
1537
+ const expirationTime = Sync;
1538
let fiber = sourceFiber.return;
1539
while (fiber !== null) {
1540
switch (fiber.tag) {
@@ -1554,10 +1579,6 @@ function dispatch(
1579
}
1580
}
1581
1557
-function captureCommitPhaseError(fiber: Fiber, error: mixed) {
1558
- return dispatch(fiber, error, Sync);
1559
-}
1560
-
1582
function computeThreadID(
1583
expirationTime: ExpirationTime,
1584
interactionThreadID: number,
@@ -2587,4 +2608,5 @@ export {
2608
interactiveUpdates,
2609
flushInteractiveUpdates,
2610
computeUniqueAsyncExpiration,
2611
+ flushPassiveEffectsBeforeSchedulingUpdateOnFiber,
2612
};
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+69
-9
@@ -604,6 +604,38 @@ describe('ReactHooks', () => {
604
expect(ReactNoop.clearYields()).toEqual(['Did commit [1]']);
605
});
606
607
+ it('flushes passive effects even with sibling deletions', () => {
608
+ function LayoutEffect(props) {
609
+ useLayoutEffect(() => {
610
+ ReactNoop.yield(`Layout effect`);
611
+ });
612
+ return <Text text="Layout" />;
613
+ }
614
+ function PassiveEffect(props) {
615
+ useEffect(() => {
616
+ ReactNoop.yield(`Passive effect`);
617
+ }, []);
618
+ return <Text text="Passive" />;
619
+ }
620
+ let passive = <PassiveEffect key="p" />;
621
+ ReactNoop.render([<LayoutEffect key="l" />, passive]);
622
+ expect(ReactNoop.flush()).toEqual(['Layout', 'Passive', 'Layout effect']);
623
+ expect(ReactNoop.getChildren()).toEqual([
624
+ span('Layout'),
625
+ span('Passive'),
626
+ ]);
627
+
628
+ // Destroying the first child shouldn't prevent the passive effect from
629
+ // being executed
630
+ ReactNoop.render([passive]);
631
+ expect(ReactNoop.flush()).toEqual(['Passive effect']);
632
+ expect(ReactNoop.getChildren()).toEqual([span('Passive')]);
633
+
634
+ // (No effects are left to flush.)
635
+ flushPassiveEffects();
636
+ expect(ReactNoop.clearYields()).toEqual(null);
637
+ });
638
+
639
it(
640
'flushes effects serially by flushing old effects before flushing ' +
641
"new ones, if they haven't already fired",
@@ -631,9 +663,9 @@ describe('ReactHooks', () => {
663
// Before the effects have a chance to flush, schedule another update
664
ReactNoop.render(<Counter count={1} />);
665
expect(ReactNoop.flush()).toEqual([
634
- 1,
635
- // The previous effect flushes before the host mutations
666
+ // The previous effect flushes before the reconciliation
667
'Committed state when effect was fired: 0',
668
+ 1,
669
]);
670
expect(ReactNoop.getChildren()).toEqual([span(1)]);
671
@@ -689,17 +721,39 @@ describe('ReactHooks', () => {
721
722
// Rendering again should flush the previous commit's effects
723
ReactNoop.render(<Counter count={1} />);
692
- ReactNoop.flushThrough([
693
- 'Count: (empty)',
694
- 'Schedule update [0]',
695
- 'Count: 0',
696
- ]);
724
+ ReactNoop.flushThrough(['Schedule update [0]', 'Count: 0']);
725
expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
726
727
+ expect(ReactNoop.flush()).toEqual([]);
728
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
729
+
730
+ flushPassiveEffects();
731
expect(ReactNoop.flush()).toEqual(['Schedule update [1]', 'Count: 1']);
732
expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
733
});
734
735
+ it('flushes serial effects before enqueueing work', () => {
736
+ let _updateCount;
737
+ function Counter(props) {
738
+ const [count, updateCount] = useState(0);
739
+ _updateCount = updateCount;
740
+ useEffect(() => {
741
+ ReactNoop.yield(`Will set count to 1`);
742
+ updateCount(1);
743
+ }, []);
744
+ return <Text text={'Count: ' + count} />;
745
+ }
746
+ ReactNoop.render(<Counter count={0} />);
747
+ expect(ReactNoop.flush()).toEqual(['Count: 0']);
748
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
749
+
750
+ // Enqueuing this update forces the passive effect to be flushed --
751
+ // updateCount(1) happens first, so 2 wins.
752
+ _updateCount(2);
753
+ expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
754
+ expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
755
+ });
756
+
757
it(
758
'in sync mode, useEffect is deferred and updates finish synchronously ' +
759
'(in a single batch)',
@@ -1160,7 +1214,10 @@ describe('ReactHooks', () => {
1214
expect(committedText).toEqual('1');
1215
1216
flushPassiveEffects();
1163
- expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 1]']);
1217
+ expect(ReactNoop.clearYields()).toEqual([
1218
+ 'Unmount normal [current: 1]',
1219
+ 'Mount normal [current: 1]',
1220
+ ]);
1221
});
1222
1223
it('force flushes passive effects before firing new layout effects', () => {
@@ -1199,7 +1256,10 @@ describe('ReactHooks', () => {
1256
expect(committedText).toEqual('1');
1257
1258
flushPassiveEffects();
1202
- expect(ReactNoop.clearYields()).toEqual(['Mount normal [current: 1]']);
1259
+ expect(ReactNoop.clearYields()).toEqual([
1260
+ 'Unmount normal [current: 1]',
1261
+ 'Mount normal [current: 1]',
1262
+ ]);
1263
});
1264
1265
it('fires all mutation effects before firing any layout effects', () => {