Skip to content

Commit

Permalink
Bugfix: Fix crash when suspending in shell during useSES re-render (f…
Browse files Browse the repository at this point in the history
…acebook#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.
  • Loading branch information
acdlite authored and AndyPengc12 committed Apr 15, 2024
1 parent fa81b2f commit 78b0d4e
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 61 deletions.
100 changes: 39 additions & 61 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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 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);
if (exitStatus !== RootInProgress) {
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.

// 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;
}

// We need to check again if something threw
// Check if something threw
if (exitStatus === RootErrored) {
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ let forwardRef;
let useImperativeHandle;
let useRef;
let useState;
let use;
let startTransition;
let waitFor;
let waitForAll;
Expand All @@ -41,6 +42,7 @@ describe('useSyncExternalStore', () => {
forwardRef = React.forwardRef;
useRef = React.useRef;
useState = React.useState;
use = React.use;
useSyncExternalStore = React.useSyncExternalStore;
startTransition = React.startTransition;

Expand Down Expand Up @@ -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>
</>,
);
},
);
});

0 comments on commit 78b0d4e

Please sign in to comment.