Skip to content

Commit cc24d0e

Browse files
authored
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.
1 parent 83fc258 commit cc24d0e

File tree

3 files changed

+37
-21
lines changed

3 files changed

+37
-21
lines changed

packages/react-reconciler/src/ReactFiberRoot.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type BaseFiberRootProperties = {|
4646
| Map<Thenable, Set<ExpirationTime>>
4747
| null,
4848

49-
pendingCommitExpirationTime: ExpirationTime,
49+
finishedExpirationTime: ExpirationTime,
5050
// A finished work-in-progress HostRoot that's ready to be committed.
5151
finishedWork: Fiber | null,
5252
// Timeout handle returned by setTimeout. Used to cancel a pending timeout, if
@@ -99,7 +99,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
9999
this.containerInfo = containerInfo;
100100
this.pendingChildren = null;
101101
this.pingCache = null;
102-
this.pendingCommitExpirationTime = NoWork;
102+
this.finishedExpirationTime = NoWork;
103103
this.finishedWork = null;
104104
this.timeoutHandle = noTimeout;
105105
this.context = null;

packages/react-reconciler/src/ReactFiberScheduler.js

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,6 @@ function resolveLocksOnRoot(root: FiberRoot, expirationTime: ExpirationTime) {
569569
firstBatch._defer &&
570570
firstBatch._expirationTime >= expirationTime
571571
) {
572-
root.finishedWork = root.current.alternate;
573-
root.pendingCommitExpirationTime = expirationTime;
574572
scheduleCallback(NormalPriority, () => {
575573
firstBatch._onComplete();
576574
return null;
@@ -689,7 +687,8 @@ export function flushControlled(fn: () => mixed): void {
689687
}
690688

691689
function prepareFreshStack(root, expirationTime) {
692-
root.pendingCommitExpirationTime = NoWork;
690+
root.finishedWork = null;
691+
root.finishedExpirationTime = NoWork;
693692

694693
const timeoutHandle = root.timeoutHandle;
695694
if (timeoutHandle !== noTimeout) {
@@ -741,10 +740,9 @@ function renderRoot(
741740
return null;
742741
}
743742

744-
if (root.pendingCommitExpirationTime === expirationTime) {
743+
if (root.finishedExpirationTime === expirationTime) {
745744
// 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);
748746
}
749747

750748
flushPassiveEffects();
@@ -867,6 +865,9 @@ function renderRoot(
867865
// something suspended, wait to commit it after a timeout.
868866
stopFinishedWorkLoopTimer();
869867

868+
root.finishedWork = root.current.alternate;
869+
root.finishedExpirationTime = expirationTime;
870+
870871
const isLocked = resolveLocksOnRoot(root, expirationTime);
871872
if (isLocked) {
872873
// This root has a lock that prevents it from committing. Exit. If we begin
@@ -905,7 +906,7 @@ function renderRoot(
905906
}
906907
// If we're already rendering synchronously, commit the root in its
907908
// errored state.
908-
return commitRoot.bind(null, root, expirationTime);
909+
return commitRoot.bind(null, root);
909910
}
910911
case RootSuspended: {
911912
if (!isSync) {
@@ -929,19 +930,19 @@ function renderRoot(
929930
// priority work to do. Instead of committing the fallback
930931
// immediately, wait for more data to arrive.
931932
root.timeoutHandle = scheduleTimeout(
932-
commitRoot.bind(null, root, expirationTime),
933+
commitRoot.bind(null, root),
933934
msUntilTimeout,
934935
);
935936
return null;
936937
}
937938
}
938939
}
939940
// The work expired. Commit immediately.
940-
return commitRoot.bind(null, root, expirationTime);
941+
return commitRoot.bind(null, root);
941942
}
942943
case RootCompleted: {
943944
// The work completed. Ready to commit.
944-
return commitRoot.bind(null, root, expirationTime);
945+
return commitRoot.bind(null, root);
945946
}
946947
default: {
947948
invariant(false, 'Unknown root exit status.');
@@ -1223,11 +1224,8 @@ function resetChildExpirationTime(completedWork: Fiber) {
12231224
completedWork.childExpirationTime = newChildExpirationTime;
12241225
}
12251226

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));
12311229
// If there are passive effects, schedule a callback to flush them. This goes
12321230
// outside commitRootImpl so that it inherits the priority of the render.
12331231
if (rootWithPendingPassiveEffects !== null) {
@@ -1240,7 +1238,7 @@ function commitRoot(root, expirationTime) {
12401238
return null;
12411239
}
12421240

1243-
function commitRootImpl(root, expirationTime) {
1241+
function commitRootImpl(root) {
12441242
flushPassiveEffects();
12451243
flushRenderPhaseStrictModeWarningsInDEV();
12461244
flushSuspensePriorityWarningInDEV();
@@ -1249,8 +1247,20 @@ function commitRootImpl(root, expirationTime) {
12491247
workPhase !== RenderPhase && workPhase !== CommitPhase,
12501248
'Should not already be working.',
12511249
);
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+
);
12541264

12551265
// commitRoot never returns a continuation; it always finishes synchronously.
12561266
// So we can clear these now to allow a new callback to be scheduled.
@@ -1794,6 +1804,12 @@ export function pingSuspendedRoot(
17941804
// Mark the time at which this ping was scheduled.
17951805
root.pingTime = suspendedTime;
17961806

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+
17971813
const currentTime = requestCurrentTime();
17981814
const priorityLevel = inferPriorityFromExpirationTime(
17991815
currentTime,

scripts/error-codes/codes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@
176176
"174": "Expected host context to exist. This error is likely caused by a bug in React. Please file an issue.",
177177
"175": "Expected prepareToHydrateHostInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.",
178178
"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.",
180180
"178": "Should have next effect. This error is likely caused by a bug in React. Please file an issue.",
181181
"179": "Should have a pending commit. This error is likely caused by a bug in React. Please file an issue.",
182182
"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.",

0 commit comments

Comments
 (0)