@samitouri / QOS-React / commits / 6514697f0c

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', () => {