Skip to content

Commit 287932c

Browse files
committed
Bugfix: "Captured" updates on legacy queue
This fixes a bug with error boundaries. Error boundaries have a notion of "captured" updates that represent errors that are thrown in its subtree during the render phase. These updates are meant to be dropped if the render is aborted. The bug happens when there's a concurrent update (an update from an interleaved event) in between when the error is thrown and when the error boundary does its second pass. The concurrent update is transferred from the pending queue onto the base queue. Usually, at this point the base queue is the same as the current queue. So when we append the pending updates to the work-in-progress queue, it also appends to the current queue. However, in the case of an error boundary's second pass, the base queue has already forked from the current queue; it includes both the "captured" updates and any concurrent updates. In that case, what we need to do is append separately to both queues. Which we weren't doing. That isn't the full story, though. You would expect that this mistake would manifest as dropping the interleaved updates. But instead what was happening is that the "captured" updates, the ones that are meant to be dropped if the render is aborted, were being added to the current queue. The reason is that the `baseQueue` structure is a circular linked list. The motivation for this was to save memory; instead of separate `first` and `last` pointers, you only need to point to `last`. But this approach does not work with structural sharing. So what was happening is that the captured updates were accidentally being added to the current queue because of the circular link. To fix this, I changed the `baseQueue` from a circular linked list to a singly-linked list so that we can take advantage of structural sharing. The "pending" queue, however, remains a circular list because it doesn't need to be persistent. This bug also affects the root fiber, which uses the same update queue implementation and also acts like an error boundary. It does not affect the hook update queue because they do not have any notion of "captured" updates. So I've left it alone for now. However, when we implement resuming, we will have to account for the same issue.
1 parent bdc5cc4 commit 287932c

File tree

3 files changed

+158
-119
lines changed

3 files changed

+158
-119
lines changed

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -984,19 +984,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
984984

985985
function logUpdateQueue(updateQueue: UpdateQueue<mixed>, depth) {
986986
log(' '.repeat(depth + 1) + 'QUEUED UPDATES');
987-
const last = updateQueue.baseQueue;
987+
const first = updateQueue.firstBaseUpdate;
988+
const last = updateQueue.lastBaseUpdate;
988989
if (last === null) {
989990
return;
990991
}
991-
const first = last.next;
992992
let update = first;
993993
if (update !== null) {
994994
do {
995995
log(
996996
' '.repeat(depth + 1) + '~',
997997
'[' + update.expirationTime + ']',
998998
);
999-
} while (update !== null && update !== first);
999+
} while (update !== null);
10001000
}
10011001

10021002
const lastPending = updateQueue.shared.pending;

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 130 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export type Update<State> = {|
115115
payload: any,
116116
callback: (() => mixed) | null,
117117

118-
next: Update<State>,
118+
next: Update<State> | null,
119119

120120
// DEV only
121121
priority?: ReactPriorityLevel,
@@ -125,7 +125,8 @@ type SharedQueue<State> = {|pending: Update<State> | null|};
125125

126126
export type UpdateQueue<State> = {|
127127
baseState: State,
128-
baseQueue: Update<State> | null,
128+
firstBaseUpdate: Update<State> | null,
129+
lastBaseUpdate: Update<State> | null,
129130
shared: SharedQueue<State>,
130131
effects: Array<Update<State>> | null,
131132
|};
@@ -154,7 +155,8 @@ if (__DEV__) {
154155
export function initializeUpdateQueue<State>(fiber: Fiber): void {
155156
const queue: UpdateQueue<State> = {
156157
baseState: fiber.memoizedState,
157-
baseQueue: null,
158+
firstBaseUpdate: null,
159+
lastBaseUpdate: null,
158160
shared: {
159161
pending: null,
160162
},
@@ -173,7 +175,8 @@ export function cloneUpdateQueue<State>(
173175
if (queue === currentQueue) {
174176
const clone: UpdateQueue<State> = {
175177
baseState: currentQueue.baseState,
176-
baseQueue: currentQueue.baseQueue,
178+
firstBaseUpdate: currentQueue.firstBaseUpdate,
179+
lastBaseUpdate: currentQueue.lastBaseUpdate,
177180
shared: currentQueue.shared,
178181
effects: currentQueue.effects,
179182
};
@@ -193,9 +196,8 @@ export function createUpdate(
193196
payload: null,
194197
callback: null,
195198

196-
next: (null: any),
199+
next: null,
197200
};
198-
update.next = update;
199201
if (__DEV__) {
200202
update.priority = getCurrentPriorityLevel();
201203
}
@@ -249,14 +251,13 @@ export function enqueueCapturedUpdate<State>(
249251
// Captured updates go only on the work-in-progress queue.
250252
const queue: UpdateQueue<State> = (workInProgress.updateQueue: any);
251253
// Append the update to the end of the list.
252-
const last = queue.baseQueue;
254+
const last = queue.lastBaseUpdate;
253255
if (last === null) {
254-
queue.baseQueue = update.next = update;
255-
update.next = update;
256+
queue.firstBaseUpdate = update;
256257
} else {
257-
update.next = last.next;
258258
last.next = update;
259259
}
260+
queue.lastBaseUpdate = update;
260261
}
261262

262263
function getStateFromUpdate<State>(
@@ -347,147 +348,160 @@ export function processUpdateQueue<State>(
347348
currentlyProcessingQueue = queue.shared;
348349
}
349350

350-
// The last rebase update that is NOT part of the base state.
351-
let baseQueue = queue.baseQueue;
351+
let firstBaseUpdate = queue.firstBaseUpdate;
352+
let lastBaseUpdate = queue.lastBaseUpdate;
352353

353-
// The last pending update that hasn't been processed yet.
354+
// Check if there are pending updates. If so, transfer them to the base queue.
354355
let pendingQueue = queue.shared.pending;
355356
if (pendingQueue !== null) {
356-
// We have new updates that haven't been processed yet.
357-
// We'll add them to the base queue.
358-
if (baseQueue !== null) {
359-
// Merge the pending queue and the base queue.
360-
let baseFirst = baseQueue.next;
361-
let pendingFirst = pendingQueue.next;
362-
baseQueue.next = pendingFirst;
363-
pendingQueue.next = baseFirst;
364-
}
357+
queue.shared.pending = null;
365358

366-
baseQueue = pendingQueue;
359+
// The pending queue is circular. Disconnect the pointer between first
360+
// and last so that it's non-circular.
361+
const lastPendingUpdate = pendingQueue;
362+
const firstPendingUpdate = lastPendingUpdate.next;
363+
lastPendingUpdate.next = null;
364+
// Append pending updates to base queue
365+
if (lastBaseUpdate === null) {
366+
firstBaseUpdate = firstPendingUpdate;
367+
} else {
368+
lastBaseUpdate.next = firstPendingUpdate;
369+
}
370+
lastBaseUpdate = lastPendingUpdate;
367371

368-
queue.shared.pending = null;
372+
// If there's a current queue, and it's different from the base queue, then
373+
// we need to transfer the updates to that queue, too. Because the base
374+
// queue is a singly-linked list with no cycles, we can append to both
375+
// lists and take advantage of structural sharing.
369376
// TODO: Pass `current` as argument
370377
const current = workInProgress.alternate;
371378
if (current !== null) {
372-
const currentQueue = current.updateQueue;
373-
if (currentQueue !== null) {
374-
currentQueue.baseQueue = pendingQueue;
379+
// This is always non-null on a ClassComponent or HostRoot
380+
const currentQueue: UpdateQueue<State> = (current.updateQueue: any);
381+
const currentLastBaseUpdate = currentQueue.lastBaseUpdate;
382+
if (currentLastBaseUpdate !== lastBaseUpdate) {
383+
if (currentLastBaseUpdate === null) {
384+
currentQueue.firstBaseUpdate = firstPendingUpdate;
385+
} else {
386+
currentLastBaseUpdate.next = firstPendingUpdate;
387+
}
388+
currentQueue.lastBaseUpdate = lastPendingUpdate;
375389
}
376390
}
377391
}
378392

379393
// These values may change as we process the queue.
380-
if (baseQueue !== null) {
381-
let first = baseQueue.next;
394+
if (firstBaseUpdate !== null) {
382395
// Iterate through the list of updates to compute the result.
383396
let newState = queue.baseState;
384397
let newExpirationTime = NoWork;
385398

386399
let newBaseState = null;
387-
let newBaseQueueFirst = null;
388-
let newBaseQueueLast = null;
389-
390-
if (first !== null) {
391-
let update = first;
392-
do {
393-
const updateExpirationTime = update.expirationTime;
394-
if (updateExpirationTime < renderExpirationTime) {
395-
// Priority is insufficient. Skip this update. If this is the first
396-
// skipped update, the previous update/state is the new base
397-
// update/state.
400+
let newFirstBaseUpdate = null;
401+
let newLastBaseUpdate = null;
402+
403+
let update = firstBaseUpdate;
404+
do {
405+
const updateExpirationTime = update.expirationTime;
406+
if (updateExpirationTime < renderExpirationTime) {
407+
// Priority is insufficient. Skip this update. If this is the first
408+
// skipped update, the previous update/state is the new base
409+
// update/state.
410+
const clone: Update<State> = {
411+
expirationTime: update.expirationTime,
412+
suspenseConfig: update.suspenseConfig,
413+
414+
tag: update.tag,
415+
payload: update.payload,
416+
callback: update.callback,
417+
418+
next: null,
419+
};
420+
if (newLastBaseUpdate === null) {
421+
newFirstBaseUpdate = newLastBaseUpdate = clone;
422+
newBaseState = newState;
423+
} else {
424+
newLastBaseUpdate = newLastBaseUpdate.next = clone;
425+
}
426+
// Update the remaining priority in the queue.
427+
if (updateExpirationTime > newExpirationTime) {
428+
newExpirationTime = updateExpirationTime;
429+
}
430+
} else {
431+
// This update does have sufficient priority.
432+
433+
if (newLastBaseUpdate !== null) {
398434
const clone: Update<State> = {
399-
expirationTime: update.expirationTime,
435+
expirationTime: Sync, // This update is going to be committed so we never want uncommit it.
400436
suspenseConfig: update.suspenseConfig,
401437

402438
tag: update.tag,
403439
payload: update.payload,
404440
callback: update.callback,
405441

406-
next: (null: any),
442+
next: null,
407443
};
408-
if (newBaseQueueLast === null) {
409-
newBaseQueueFirst = newBaseQueueLast = clone;
410-
newBaseState = newState;
411-
} else {
412-
newBaseQueueLast = newBaseQueueLast.next = clone;
413-
}
414-
// Update the remaining priority in the queue.
415-
if (updateExpirationTime > newExpirationTime) {
416-
newExpirationTime = updateExpirationTime;
417-
}
418-
} else {
419-
// This update does have sufficient priority.
420-
421-
if (newBaseQueueLast !== null) {
422-
const clone: Update<State> = {
423-
expirationTime: Sync, // This update is going to be committed so we never want uncommit it.
424-
suspenseConfig: update.suspenseConfig,
425-
426-
tag: update.tag,
427-
payload: update.payload,
428-
callback: update.callback,
429-
430-
next: (null: any),
431-
};
432-
newBaseQueueLast = newBaseQueueLast.next = clone;
433-
}
434-
435-
// Mark the event time of this update as relevant to this render pass.
436-
// TODO: This should ideally use the true event time of this update rather than
437-
// its priority which is a derived and not reverseable value.
438-
// TODO: We should skip this update if it was already committed but currently
439-
// we have no way of detecting the difference between a committed and suspended
440-
// update here.
441-
markRenderEventTimeAndConfig(
442-
updateExpirationTime,
443-
update.suspenseConfig,
444-
);
445-
446-
// Process this update.
447-
newState = getStateFromUpdate(
448-
workInProgress,
449-
queue,
450-
update,
451-
newState,
452-
props,
453-
instance,
454-
);
455-
const callback = update.callback;
456-
if (callback !== null) {
457-
workInProgress.effectTag |= Callback;
458-
let effects = queue.effects;
459-
if (effects === null) {
460-
queue.effects = [update];
461-
} else {
462-
effects.push(update);
463-
}
464-
}
444+
newLastBaseUpdate = newLastBaseUpdate.next = clone;
465445
}
466-
update = update.next;
467-
if (update === null || update === first) {
468-
pendingQueue = queue.shared.pending;
469-
if (pendingQueue === null) {
470-
break;
446+
447+
// Mark the event time of this update as relevant to this render pass.
448+
// TODO: This should ideally use the true event time of this update rather than
449+
// its priority which is a derived and not reverseable value.
450+
// TODO: We should skip this update if it was already committed but currently
451+
// we have no way of detecting the difference between a committed and suspended
452+
// update here.
453+
markRenderEventTimeAndConfig(
454+
updateExpirationTime,
455+
update.suspenseConfig,
456+
);
457+
458+
// Process this update.
459+
newState = getStateFromUpdate(
460+
workInProgress,
461+
queue,
462+
update,
463+
newState,
464+
props,
465+
instance,
466+
);
467+
const callback = update.callback;
468+
if (callback !== null) {
469+
workInProgress.effectTag |= Callback;
470+
let effects = queue.effects;
471+
if (effects === null) {
472+
queue.effects = [update];
471473
} else {
472-
// An update was scheduled from inside a reducer. Add the new
473-
// pending updates to the end of the list and keep processing.
474-
update = baseQueue.next = pendingQueue.next;
475-
pendingQueue.next = first;
476-
queue.baseQueue = baseQueue = pendingQueue;
477-
queue.shared.pending = null;
474+
effects.push(update);
478475
}
479476
}
480-
} while (true);
481-
}
477+
}
478+
update = update.next;
479+
if (update === null) {
480+
pendingQueue = queue.shared.pending;
481+
if (pendingQueue === null) {
482+
break;
483+
} else {
484+
// An update was scheduled from inside a reducer. Add the new
485+
// pending updates to the end of the list and keep processing.
486+
const lastPendingUpdate = pendingQueue;
487+
// Intentionally unsound. Pending updates form a circular list, but we
488+
// unravel them when transferring them to the base queue.
489+
const firstPendingUpdate = ((lastPendingUpdate.next: any): Update<State>);
490+
lastPendingUpdate.next = null;
491+
update = firstPendingUpdate;
492+
queue.lastBaseUpdate = lastPendingUpdate;
493+
queue.shared.pending = null;
494+
}
495+
}
496+
} while (true);
482497

483-
if (newBaseQueueLast === null) {
498+
if (newLastBaseUpdate === null) {
484499
newBaseState = newState;
485-
} else {
486-
newBaseQueueLast.next = (newBaseQueueFirst: any);
487500
}
488501

489502
queue.baseState = ((newBaseState: any): State);
490-
queue.baseQueue = newBaseQueueLast;
503+
queue.firstBaseUpdate = newFirstBaseUpdate;
504+
queue.lastBaseUpdate = newLastBaseUpdate;
491505

492506
// Set the remaining expiration time to be whatever is remaining in the queue.
493507
// This should be fine because the only two other things that contribute to

packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,31 @@ describe('ReactIncrementalErrorHandling', () => {
17291729
]);
17301730
});
17311731

1732+
it('uncaught errors should be discarded if the render is aborted', async () => {
1733+
const root = ReactNoop.createRoot();
1734+
1735+
function Oops() {
1736+
Scheduler.unstable_yieldValue('Oops');
1737+
throw Error('Oops');
1738+
}
1739+
1740+
await ReactNoop.act(async () => {
1741+
ReactNoop.discreteUpdates(() => {
1742+
root.render(<Oops />);
1743+
});
1744+
// Render past the component that throws, then yield.
1745+
expect(Scheduler).toFlushAndYieldThrough(['Oops']);
1746+
expect(root).toMatchRenderedOutput(null);
1747+
// Interleaved update. When the root completes, instead of throwing the
1748+
// error, it should try rendering again. This update will cause it to
1749+
// recover gracefully.
1750+
root.render('Everything is fine.');
1751+
});
1752+
1753+
// Should finish without throwing.
1754+
expect(root).toMatchRenderedOutput('Everything is fine.');
1755+
});
1756+
17321757
if (global.__PERSISTENT__) {
17331758
it('regression test: should fatal if error is thrown at the root', () => {
17341759
const root = ReactNoop.createRoot();

0 commit comments

Comments
 (0)