Bugfix: Fix crash when suspending in shell during useSES re-render (#27199)

This adds a regression test for a bug where, after a store mutation, the
updated data causes the shell of the app to suspend.

When re-rendering due to a concurrent store mutation, we must check for
the RootDidNotComplete exit status again.

As a follow-up, I'm going to look into to cleaning up the places where
we check the exit status, so bugs like these are less likely. I think we
might be able to combine most of it into a single switch statement.
This commit is contained in:
Andrew Clark
2023-08-08 14:11:11 -04:00
committed by GitHub
parent cb3404a0cc
commit 201becd3d2
2 changed files with 120 additions and 60 deletions
+38 -60
View File
@@ -892,58 +892,39 @@ export function performConcurrentWorkOnRoot(
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
if (exitStatus !== RootInProgress) {
if (exitStatus === RootErrored) {
// If something threw an error, try rendering one more time. We'll
// render synchronously to block concurrent data mutations, and we'll
// includes all pending updates are included. If it still fails after
// the second attempt, we'll give up and commit the resulting tree.
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
root,
originallyAttemptedLanes,
);
if (errorRetryLanes !== NoLanes) {
lanes = errorRetryLanes;
exitStatus = recoverFromConcurrentError(
root,
originallyAttemptedLanes,
errorRetryLanes,
);
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes);
ensureRootIsScheduled(root);
throw fatalError;
}
let renderWasConcurrent = shouldTimeSlice;
do {
if (exitStatus === RootDidNotComplete) {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
markRootSuspended(root, lanes);
} else {
// The render completed.
if (exitStatus === RootDidNotComplete) {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
markRootSuspended(root, lanes);
} else {
// The render completed.
// Check if this render may have yielded to a concurrent event, and if so,
// confirm that any newly rendered stores are consistent.
// TODO: It's possible that even a concurrent render may never have yielded
// to the main thread, if it was fast enough, or if it expired. We could
// skip the consistency check in that case, too.
const finishedWork: Fiber = (root.current.alternate: any);
if (
renderWasConcurrent &&
!isRenderConsistentWithExternalStores(finishedWork)
) {
// A store was mutated in an interleaved event. Render again,
// synchronously, to block further mutations.
exitStatus = renderRootSync(root, lanes);
// We assume the tree is now consistent because we didn't yield to any
// concurrent events.
renderWasConcurrent = false;
// Need to check the exit status again.
continue;
}
// Check if this render may have yielded to a concurrent event, and if so,
// confirm that any newly rendered stores are consistent.
// TODO: It's possible that even a concurrent render may never have yielded
// to the main thread, if it was fast enough, or if it expired. We could
// skip the consistency check in that case, too.
const renderWasConcurrent = !includesBlockingLane(root, lanes);
const finishedWork: Fiber = (root.current.alternate: any);
if (
renderWasConcurrent &&
!isRenderConsistentWithExternalStores(finishedWork)
) {
// A store was mutated in an interleaved event. Render again,
// synchronously, to block further mutations.
exitStatus = renderRootSync(root, lanes);
// We need to check again if something threw
// Check if something threw
if (exitStatus === RootErrored) {
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
@@ -957,8 +938,7 @@ export function performConcurrentWorkOnRoot(
originallyAttemptedLanes,
errorRetryLanes,
);
// We assume the tree is now consistent because we didn't yield to any
// concurrent events.
renderWasConcurrent = false;
}
}
if (exitStatus === RootFatalErrored) {
@@ -969,16 +949,14 @@ export function performConcurrentWorkOnRoot(
throw fatalError;
}
// FIXME: Need to check for RootDidNotComplete again. The factoring here
// isn't ideal.
// We now have a consistent tree. The next step is either to commit it,
// or, if something suspended, wait to commit it after a timeout.
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
}
// We now have a consistent tree. The next step is either to commit it,
// or, if something suspended, wait to commit it after a timeout.
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
}
break;
} while (true);
}
ensureRootIsScheduled(root);
@@ -19,6 +19,7 @@ let forwardRef;
let useImperativeHandle;
let useRef;
let useState;
let use;
let startTransition;
let waitFor;
let waitForAll;
@@ -41,6 +42,7 @@ describe('useSyncExternalStore', () => {
forwardRef = React.forwardRef;
useRef = React.useRef;
useState = React.useState;
use = React.use;
useSyncExternalStore = React.useSyncExternalStore;
startTransition = React.startTransition;
@@ -212,4 +214,84 @@ describe('useSyncExternalStore', () => {
});
assertLog(['value:initial']);
});
test(
'regression: suspending in shell after synchronously patching ' +
'up store mutation',
async () => {
// Tests a case where a store is mutated during a concurrent event, then
// during the sync re-render, a synchronous render is triggered.
const store = createExternalStore('Initial');
let resolve;
const promise = new Promise(r => {
resolve = r;
});
function A() {
const value = useSyncExternalStore(store.subscribe, store.getState);
if (value === 'Updated') {
try {
use(promise);
} catch (x) {
Scheduler.log('Suspend A');
throw x;
}
}
return <Text text={'A: ' + value} />;
}
function B() {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={'B: ' + value} />;
}
function App() {
return (
<>
<span>
<A />
</span>
<span>
<B />
</span>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
// A and B both read from the same store. Partially render A.
startTransition(() => root.render(<App />));
// A reads the initial value of the store.
await waitFor(['A: Initial']);
// Before B renders, mutate the store.
store.set('Updated');
});
assertLog([
// B reads the updated value of the store.
'B: Updated',
// This should a synchronous re-render of A using the updated value. In
// this test, this causes A to suspend.
'Suspend A',
]);
// Nothing has committed, because A suspended and no fallback
// was provided.
expect(root).toMatchRenderedOutput(null);
// Resolve the data and finish rendering.
await act(() => resolve());
assertLog(['A: Updated', 'B: Updated']);
expect(root).toMatchRenderedOutput(
<>
<span>A: Updated</span>
<span>B: Updated</span>
</>,
);
},
);
});