@samitouri / QOS-React-2 / commits / cc24d0ea56

Invariant that throws when committing wrong tree (#15517)

If React finishes rendering a tree, delays committing it (e.g. Suspense), then subsequently starts over or renders a new tree, the pending tree is no longer valid. That's because rendering a new work-in progress mutates the old one in place. The current structure of the work loop makes this hard to reason about because, although `renderRoot` and `commitRoot` are separate functions, they can't be interleaved. If they are interleaved by accident, it either results in inconsistent render output or invariant violations that are hard to debug. This commit adds an invariant that throws if the new tree is the same as the old one. This won't prevent all bugs of this class, but it should catch the most common kind. To implement the invariant, I store the finished tree on a field on the root. We already had a field for this, but it was only being used for the unstable `createBatch` feature. A more rigorous way to address this type of problem could be to unify `renderRoot` and `commitRoot` into a single function, so that it's harder to accidentally interleave the two phases. I plan to do something like this in a follow-up.

Andrew Clark committed May 13, 2019 at 16:15 UTC cc24d0ea56b0538d1ac61dc09faedd70ced5bb47
3 files changed +37 -21
packages/react-reconciler/src/ReactFiberRoot.js
+2 -2
@@ -46,7 +46,7 @@ type BaseFiberRootProperties = {|
46 | Map<Thenable, Set<ExpirationTime>>
47 | null,
48
49 - pendingCommitExpirationTime: ExpirationTime,
49 + finishedExpirationTime: ExpirationTime,
50 // A finished work-in-progress HostRoot that's ready to be committed.
51 finishedWork: Fiber | null,
52 // Timeout handle returned by setTimeout. Used to cancel a pending timeout, if
@@ -99,7 +99,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
99 this.containerInfo = containerInfo;
100 this.pendingChildren = null;
101 this.pingCache = null;
102 - this.pendingCommitExpirationTime = NoWork;
102 + this.finishedExpirationTime = NoWork;
103 this.finishedWork = null;
104 this.timeoutHandle = noTimeout;
105 this.context = null;
packages/react-reconciler/src/ReactFiberScheduler.js
+34 -18
@@ -569,8 +569,6 @@ function resolveLocksOnRoot(root: FiberRoot, expirationTime: ExpirationTime) {
569 firstBatch._defer &&
570 firstBatch._expirationTime >= expirationTime
571 ) {
572 - root.finishedWork = root.current.alternate;
573 - root.pendingCommitExpirationTime = expirationTime;
572 scheduleCallback(NormalPriority, () => {
573 firstBatch._onComplete();
574 return null;
@@ -689,7 +687,8 @@ export function flushControlled(fn: () => mixed): void {
687 }
688
689 function prepareFreshStack(root, expirationTime) {
692 - root.pendingCommitExpirationTime = NoWork;
690 + root.finishedWork = null;
691 + root.finishedExpirationTime = NoWork;
692
693 const timeoutHandle = root.timeoutHandle;
694 if (timeoutHandle !== noTimeout) {
@@ -741,10 +740,9 @@ function renderRoot(
740 return null;
741 }
742
744 - if (root.pendingCommitExpirationTime === expirationTime) {
743 + if (root.finishedExpirationTime === expirationTime) {
744 // There's already a pending commit at this expiration time.
746 - root.pendingCommitExpirationTime = NoWork;
747 - return commitRoot.bind(null, root, expirationTime);
745 + return commitRoot.bind(null, root);
746 }
747
748 flushPassiveEffects();
@@ -867,6 +865,9 @@ function renderRoot(
865 // something suspended, wait to commit it after a timeout.
866 stopFinishedWorkLoopTimer();
867
868 + root.finishedWork = root.current.alternate;
869 + root.finishedExpirationTime = expirationTime;
870 +
871 const isLocked = resolveLocksOnRoot(root, expirationTime);
872 if (isLocked) {
873 // This root has a lock that prevents it from committing. Exit. If we begin
@@ -905,7 +906,7 @@ function renderRoot(
906 }
907 // If we're already rendering synchronously, commit the root in its
908 // errored state.
908 - return commitRoot.bind(null, root, expirationTime);
909 + return commitRoot.bind(null, root);
910 }
911 case RootSuspended: {
912 if (!isSync) {
@@ -929,7 +930,7 @@ function renderRoot(
930 // priority work to do. Instead of committing the fallback
931 // immediately, wait for more data to arrive.
932 root.timeoutHandle = scheduleTimeout(
932 - commitRoot.bind(null, root, expirationTime),
933 + commitRoot.bind(null, root),
934 msUntilTimeout,
935 );
936 return null;
@@ -937,11 +938,11 @@ function renderRoot(
938 }
939 }
940 // The work expired. Commit immediately.
940 - return commitRoot.bind(null, root, expirationTime);
941 + return commitRoot.bind(null, root);
942 }
943 case RootCompleted: {
944 // The work completed. Ready to commit.
944 - return commitRoot.bind(null, root, expirationTime);
945 + return commitRoot.bind(null, root);
946 }
947 default: {
948 invariant(false, 'Unknown root exit status.');
@@ -1223,11 +1224,8 @@ function resetChildExpirationTime(completedWork: Fiber) {
1224 completedWork.childExpirationTime = newChildExpirationTime;
1225 }
1226
1226 -function commitRoot(root, expirationTime) {
1227 - runWithPriority(
1228 - ImmediatePriority,
1229 - commitRootImpl.bind(null, root, expirationTime),
1230 - );
1227 +function commitRoot(root) {
1228 + runWithPriority(ImmediatePriority, commitRootImpl.bind(null, root));
1229 // If there are passive effects, schedule a callback to flush them. This goes
1230 // outside commitRootImpl so that it inherits the priority of the render.
1231 if (rootWithPendingPassiveEffects !== null) {
@@ -1240,7 +1238,7 @@ function commitRoot(root, expirationTime) {
1238 return null;
1239 }
1240
1243 -function commitRootImpl(root, expirationTime) {
1241 +function commitRootImpl(root) {
1242 flushPassiveEffects();
1243 flushRenderPhaseStrictModeWarningsInDEV();
1244 flushSuspensePriorityWarningInDEV();
@@ -1249,8 +1247,20 @@ function commitRootImpl(root, expirationTime) {
1247 workPhase !== RenderPhase && workPhase !== CommitPhase,
1248 'Should not already be working.',
1249 );
1252 - const finishedWork = root.current.alternate;
1253 - invariant(finishedWork !== null, 'Should have a work-in-progress root.');
1250 +
1251 + const finishedWork = root.finishedWork;
1252 + const expirationTime = root.finishedExpirationTime;
1253 + if (finishedWork === null) {
1254 + return null;
1255 + }
1256 + root.finishedWork = null;
1257 + root.finishedExpirationTime = NoWork;
1258 +
1259 + invariant(
1260 + finishedWork !== root.current,
1261 + 'Cannot commit the same tree as before. This error is likely caused by ' +
1262 + 'a bug in React. Please file an issue.',
1263 + );
1264
1265 // commitRoot never returns a continuation; it always finishes synchronously.
1266 // So we can clear these now to allow a new callback to be scheduled.
@@ -1794,6 +1804,12 @@ export function pingSuspendedRoot(
1804 // Mark the time at which this ping was scheduled.
1805 root.pingTime = suspendedTime;
1806
1807 + if (root.finishedExpirationTime === suspendedTime) {
1808 + // If there's a pending fallback waiting to commit, throw it away.
1809 + root.finishedExpirationTime = NoWork;
1810 + root.finishedWork = null;
1811 + }
1812 +
1813 const currentTime = requestCurrentTime();
1814 const priorityLevel = inferPriorityFromExpirationTime(
1815 currentTime,
scripts/error-codes/codes.json
+1 -1
@@ -176,7 +176,7 @@
176 "174": "Expected host context to exist. This error is likely caused by a bug in React. Please file an issue.",
177 "175": "Expected prepareToHydrateHostInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
178 "176": "Expected prepareToHydrateHostTextInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
179 - "177": "Cannot commit the same tree as before. This is probably a bug related to the return field. This error is likely caused by a bug in React. Please file an issue.",
179 + "177": "Cannot commit the same tree as before. This error is likely caused by a bug in React. Please file an issue.",
180 "178": "Should have next effect. This error is likely caused by a bug in React. Please file an issue.",
181 "179": "Should have a pending commit. This error is likely caused by a bug in React. Please file an issue.",
182 "180": "Commit phase errors should be scheduled to recover with task priority. This error is likely caused by a bug in React. Please file an issue.",