Skip to content

Commit 201becd

Browse files
authored
Bugfix: Fix crash when suspending in shell during useSES re-render (facebook#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.
1 parent cb3404a commit 201becd

File tree

2 files changed

+121
-61
lines changed

2 files changed

+121
-61
lines changed

packages/react-reconciler/src/ReactFiberWorkLoop.js

+39-61
Original file line numberDiff line numberDiff line change
@@ -892,58 +892,39 @@ export function performConcurrentWorkOnRoot(
892892
let exitStatus = shouldTimeSlice
893893
? renderRootConcurrent(root, lanes)
894894
: renderRootSync(root, lanes);
895-
if (exitStatus !== RootInProgress) {
896-
if (exitStatus === RootErrored) {
897-
// If something threw an error, try rendering one more time. We'll
898-
// render synchronously to block concurrent data mutations, and we'll
899-
// includes all pending updates are included. If it still fails after
900-
// the second attempt, we'll give up and commit the resulting tree.
901-
const originallyAttemptedLanes = lanes;
902-
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
903-
root,
904-
originallyAttemptedLanes,
905-
);
906-
if (errorRetryLanes !== NoLanes) {
907-
lanes = errorRetryLanes;
908-
exitStatus = recoverFromConcurrentError(
909-
root,
910-
originallyAttemptedLanes,
911-
errorRetryLanes,
912-
);
913-
}
914-
}
915-
if (exitStatus === RootFatalErrored) {
916-
const fatalError = workInProgressRootFatalError;
917-
prepareFreshStack(root, NoLanes);
918-
markRootSuspended(root, lanes);
919-
ensureRootIsScheduled(root);
920-
throw fatalError;
921-
}
922895

923-
if (exitStatus === RootDidNotComplete) {
924-
// The render unwound without completing the tree. This happens in special
925-
// cases where need to exit the current render without producing a
926-
// consistent tree or committing.
927-
markRootSuspended(root, lanes);
928-
} else {
929-
// The render completed.
930-
931-
// Check if this render may have yielded to a concurrent event, and if so,
932-
// confirm that any newly rendered stores are consistent.
933-
// TODO: It's possible that even a concurrent render may never have yielded
934-
// to the main thread, if it was fast enough, or if it expired. We could
935-
// skip the consistency check in that case, too.
936-
const renderWasConcurrent = !includesBlockingLane(root, lanes);
937-
const finishedWork: Fiber = (root.current.alternate: any);
938-
if (
939-
renderWasConcurrent &&
940-
!isRenderConsistentWithExternalStores(finishedWork)
941-
) {
942-
// A store was mutated in an interleaved event. Render again,
943-
// synchronously, to block further mutations.
944-
exitStatus = renderRootSync(root, lanes);
896+
if (exitStatus !== RootInProgress) {
897+
let renderWasConcurrent = shouldTimeSlice;
898+
do {
899+
if (exitStatus === RootDidNotComplete) {
900+
// The render unwound without completing the tree. This happens in special
901+
// cases where need to exit the current render without producing a
902+
// consistent tree or committing.
903+
markRootSuspended(root, lanes);
904+
} else {
905+
// The render completed.
906+
907+
// Check if this render may have yielded to a concurrent event, and if so,
908+
// confirm that any newly rendered stores are consistent.
909+
// TODO: It's possible that even a concurrent render may never have yielded
910+
// to the main thread, if it was fast enough, or if it expired. We could
911+
// skip the consistency check in that case, too.
912+
const finishedWork: Fiber = (root.current.alternate: any);
913+
if (
914+
renderWasConcurrent &&
915+
!isRenderConsistentWithExternalStores(finishedWork)
916+
) {
917+
// A store was mutated in an interleaved event. Render again,
918+
// synchronously, to block further mutations.
919+
exitStatus = renderRootSync(root, lanes);
920+
// We assume the tree is now consistent because we didn't yield to any
921+
// concurrent events.
922+
renderWasConcurrent = false;
923+
// Need to check the exit status again.
924+
continue;
925+
}
945926

946-
// We need to check again if something threw
927+
// Check if something threw
947928
if (exitStatus === RootErrored) {
948929
const originallyAttemptedLanes = lanes;
949930
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
@@ -957,8 +938,7 @@ export function performConcurrentWorkOnRoot(
957938
originallyAttemptedLanes,
958939
errorRetryLanes,
959940
);
960-
// We assume the tree is now consistent because we didn't yield to any
961-
// concurrent events.
941+
renderWasConcurrent = false;
962942
}
963943
}
964944
if (exitStatus === RootFatalErrored) {
@@ -969,16 +949,14 @@ export function performConcurrentWorkOnRoot(
969949
throw fatalError;
970950
}
971951

972-
// FIXME: Need to check for RootDidNotComplete again. The factoring here
973-
// isn't ideal.
952+
// We now have a consistent tree. The next step is either to commit it,
953+
// or, if something suspended, wait to commit it after a timeout.
954+
root.finishedWork = finishedWork;
955+
root.finishedLanes = lanes;
956+
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
974957
}
975-
976-
// We now have a consistent tree. The next step is either to commit it,
977-
// or, if something suspended, wait to commit it after a timeout.
978-
root.finishedWork = finishedWork;
979-
root.finishedLanes = lanes;
980-
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
981-
}
958+
break;
959+
} while (true);
982960
}
983961

984962
ensureRootIsScheduled(root);

packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js

+82
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ let forwardRef;
1919
let useImperativeHandle;
2020
let useRef;
2121
let useState;
22+
let use;
2223
let startTransition;
2324
let waitFor;
2425
let waitForAll;
@@ -41,6 +42,7 @@ describe('useSyncExternalStore', () => {
4142
forwardRef = React.forwardRef;
4243
useRef = React.useRef;
4344
useState = React.useState;
45+
use = React.use;
4446
useSyncExternalStore = React.useSyncExternalStore;
4547
startTransition = React.startTransition;
4648

@@ -212,4 +214,84 @@ describe('useSyncExternalStore', () => {
212214
});
213215
assertLog(['value:initial']);
214216
});
217+
218+
test(
219+
'regression: suspending in shell after synchronously patching ' +
220+
'up store mutation',
221+
async () => {
222+
// Tests a case where a store is mutated during a concurrent event, then
223+
// during the sync re-render, a synchronous render is triggered.
224+
225+
const store = createExternalStore('Initial');
226+
227+
let resolve;
228+
const promise = new Promise(r => {
229+
resolve = r;
230+
});
231+
232+
function A() {
233+
const value = useSyncExternalStore(store.subscribe, store.getState);
234+
235+
if (value === 'Updated') {
236+
try {
237+
use(promise);
238+
} catch (x) {
239+
Scheduler.log('Suspend A');
240+
throw x;
241+
}
242+
}
243+
244+
return <Text text={'A: ' + value} />;
245+
}
246+
247+
function B() {
248+
const value = useSyncExternalStore(store.subscribe, store.getState);
249+
return <Text text={'B: ' + value} />;
250+
}
251+
252+
function App() {
253+
return (
254+
<>
255+
<span>
256+
<A />
257+
</span>
258+
<span>
259+
<B />
260+
</span>
261+
</>
262+
);
263+
}
264+
265+
const root = ReactNoop.createRoot();
266+
await act(async () => {
267+
// A and B both read from the same store. Partially render A.
268+
startTransition(() => root.render(<App />));
269+
// A reads the initial value of the store.
270+
await waitFor(['A: Initial']);
271+
272+
// Before B renders, mutate the store.
273+
store.set('Updated');
274+
});
275+
assertLog([
276+
// B reads the updated value of the store.
277+
'B: Updated',
278+
// This should a synchronous re-render of A using the updated value. In
279+
// this test, this causes A to suspend.
280+
'Suspend A',
281+
]);
282+
// Nothing has committed, because A suspended and no fallback
283+
// was provided.
284+
expect(root).toMatchRenderedOutput(null);
285+
286+
// Resolve the data and finish rendering.
287+
await act(() => resolve());
288+
assertLog(['A: Updated', 'B: Updated']);
289+
expect(root).toMatchRenderedOutput(
290+
<>
291+
<span>A: Updated</span>
292+
<span>B: Updated</span>
293+
</>,
294+
);
295+
},
296+
);
215297
});

0 commit comments

Comments
 (0)