Make prerendering always non-blocking with fix (#31452)
We've previously failed to land this change due to some internal apps seeing infinite render loops due to external store state updates during render. It turns out that since the `renderWasConcurrent` var was moved into the do block, the sync render triggered from the external store check was stuck with a `RootSuspended` `exitStatus`. So this is not unique to sibling prerendering but more generally related to how we handle update to a sync external store during render. We've tested this build against local repros which now render without crashes. We will try to add a unit test to cover the scenario as well. --------- Co-authored-by: Andrew Clark <git@andrewclark.io> Co-authored-by: Rick Hanlon <rickhanlonii@fb.com>
Jack Pope committed
Nov 8, 2024 at 12:38 UTC
989af12f72080c17db03ead91d99b6394a215564
7 files changed
+428
-129
packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js
+1
-1
@@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => {
744
// Because it suspended, it remains on the current path
745
expect(div.textContent).toBe('/path/a');
746
});
747
- assertLog([]);
747
+ assertLog(gate('enableSiblingPrerendering') ? ['Suspend! [/path/b]'] : []);
748
749
await act(async () => {
750
resolvePromise();
packages/react-reconciler/src/ReactFiberLane.js
+4
-2
@@ -765,12 +765,14 @@ export function markRootSuspended(
765
root: FiberRoot,
766
suspendedLanes: Lanes,
767
spawnedLane: Lane,
768
- didSkipSuspendedSiblings: boolean,
768
+ didAttemptEntireTree: boolean,
769
) {
770
+ // TODO: Split this into separate functions for marking the root at the end of
771
+ // a render attempt versus suspending while the root is still in progress.
772
root.suspendedLanes |= suspendedLanes;
773
root.pingedLanes &= ~suspendedLanes;
774
773
- if (enableSiblingPrerendering && !didSkipSuspendedSiblings) {
775
+ if (enableSiblingPrerendering && didAttemptEntireTree) {
776
// Mark these lanes as warm so we know there's nothing else to work on.
777
root.warmLanes |= suspendedLanes;
778
} else {
packages/react-reconciler/src/ReactFiberRootScheduler.js
+16
-4
@@ -18,6 +18,7 @@ import {
18
disableSchedulerTimeoutInWorkLoop,
19
enableProfilerTimer,
20
enableProfilerNestedUpdatePhase,
21
+ enableSiblingPrerendering,
22
} from 'shared/ReactFeatureFlags';
23
import {
24
NoLane,
@@ -29,6 +30,7 @@ import {
30
markStarvedLanesAsExpired,
31
claimNextTransitionLane,
32
getNextLanesToFlushSync,
33
+ checkIfRootIsPrerendering,
34
} from './ReactFiberLane';
35
import {
36
CommitContext,
@@ -206,7 +208,10 @@ function flushSyncWorkAcrossRoots_impl(
208
? workInProgressRootRenderLanes
209
: NoLanes,
210
);
209
- if (includesSyncLane(nextLanes)) {
211
+ if (
212
+ includesSyncLane(nextLanes) &&
213
+ !checkIfRootIsPrerendering(root, nextLanes)
214
+ ) {
215
// This root has pending sync work. Flush it now.
216
didPerformSomeWork = true;
217
performSyncWorkOnRoot(root, nextLanes);
@@ -341,7 +346,13 @@ function scheduleTaskForRootDuringMicrotask(
346
}
347
348
// Schedule a new callback in the host environment.
344
- if (includesSyncLane(nextLanes)) {
349
+ if (
350
+ includesSyncLane(nextLanes) &&
351
+ // If we're prerendering, then we should use the concurrent work loop
352
+ // even if the lanes are synchronous, so that prerendering never blocks
353
+ // the main thread.
354
+ !(enableSiblingPrerendering && checkIfRootIsPrerendering(root, nextLanes))
355
+ ) {
356
// Synchronous work is always flushed at the end of the microtask, so we
357
// don't need to schedule an additional task.
358
if (existingCallbackNode !== null) {
@@ -375,9 +386,10 @@ function scheduleTaskForRootDuringMicrotask(
386
387
let schedulerPriorityLevel;
388
switch (lanesToEventPriority(nextLanes)) {
389
+ // Scheduler does have an "ImmediatePriority", but now that we use
390
+ // microtasks for sync work we no longer use that. Any sync work that
391
+ // reaches this path is meant to be time sliced.
392
case DiscreteEventPriority:
379
- schedulerPriorityLevel = ImmediateSchedulerPriority;
380
- break;
393
case ContinuousEventPriority:
394
schedulerPriorityLevel = UserBlockingSchedulerPriority;
395
break;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+177
-121
@@ -764,11 +764,12 @@ export function scheduleUpdateOnFiber(
764
// The incoming update might unblock the current render. Interrupt the
765
// current attempt and restart from the top.
766
prepareFreshStack(root, NoLanes);
767
+ const didAttemptEntireTree = false;
768
markRootSuspended(
769
root,
770
workInProgressRootRenderLanes,
771
workInProgressDeferredLane,
771
- workInProgressRootDidSkipSuspendedSiblings,
772
+ didAttemptEntireTree,
773
);
774
}
775
@@ -831,11 +832,12 @@ export function scheduleUpdateOnFiber(
832
// effect of interrupting the current render and switching to the update.
833
// TODO: Make sure this doesn't override pings that happen while we've
834
// already started rendering.
835
+ const didAttemptEntireTree = false;
836
markRootSuspended(
837
root,
838
workInProgressRootRenderLanes,
839
workInProgressDeferredLane,
838
- workInProgressRootDidSkipSuspendedSiblings,
840
+ didAttemptEntireTree,
841
);
842
}
843
}
@@ -897,100 +899,121 @@ export function performWorkOnRoot(
899
// for too long ("expired" work, to prevent starvation), or we're in
900
// sync-updates-by-default mode.
901
const shouldTimeSlice =
900
- !forceSync &&
901
- !includesBlockingLane(lanes) &&
902
- !includesExpiredLane(root, lanes);
902
+ (!forceSync &&
903
+ !includesBlockingLane(lanes) &&
904
+ !includesExpiredLane(root, lanes)) ||
905
+ // If we're prerendering, then we should use the concurrent work loop
906
+ // even if the lanes are synchronous, so that prerendering never blocks
907
+ // the main thread.
908
+ // TODO: We should consider doing this whenever a sync lane is suspended,
909
+ // even for regular pings.
910
+ (enableSiblingPrerendering && checkIfRootIsPrerendering(root, lanes));
911
+
912
let exitStatus = shouldTimeSlice
913
? renderRootConcurrent(root, lanes)
905
- : renderRootSync(root, lanes);
906
-
907
- if (exitStatus !== RootInProgress) {
908
- let renderWasConcurrent = shouldTimeSlice;
909
- do {
910
- if (exitStatus === RootDidNotComplete) {
911
- // The render unwound without completing the tree. This happens in special
912
- // cases where need to exit the current render without producing a
913
- // consistent tree or committing.
914
- markRootSuspended(
914
+ : renderRootSync(root, lanes, true);
915
+
916
+ let renderWasConcurrent = shouldTimeSlice;
917
+
918
+ do {
919
+ if (exitStatus === RootInProgress) {
920
+ // Render phase is still in progress.
921
+ if (
922
+ enableSiblingPrerendering &&
923
+ workInProgressRootIsPrerendering &&
924
+ !shouldTimeSlice
925
+ ) {
926
+ // We're in prerendering mode, but time slicing is not enabled. This
927
+ // happens when something suspends during a synchronous update. Exit the
928
+ // the work loop. When we resume, we'll use the concurrent work loop so
929
+ // that prerendering is non-blocking.
930
+ //
931
+ // Mark the root as suspended. Usually we do this at the end of the
932
+ // render phase, but we do it here so that we resume in
933
+ // prerendering mode.
934
+ // TODO: Consider always calling markRootSuspended immediately.
935
+ // Needs to be *after* we attach a ping listener, though.
936
+ const didAttemptEntireTree = false;
937
+ markRootSuspended(root, lanes, NoLane, didAttemptEntireTree);
938
+ }
939
+ break;
940
+ } else if (exitStatus === RootDidNotComplete) {
941
+ // The render unwound without completing the tree. This happens in special
942
+ // cases where need to exit the current render without producing a
943
+ // consistent tree or committing.
944
+ const didAttemptEntireTree = !workInProgressRootDidSkipSuspendedSiblings;
945
+ markRootSuspended(root, lanes, NoLane, didAttemptEntireTree);
946
+ } else {
947
+ // The render completed.
948
+
949
+ // Check if this render may have yielded to a concurrent event, and if so,
950
+ // confirm that any newly rendered stores are consistent.
951
+ // TODO: It's possible that even a concurrent render may never have yielded
952
+ // to the main thread, if it was fast enough, or if it expired. We could
953
+ // skip the consistency check in that case, too.
954
+ const finishedWork: Fiber = (root.current.alternate: any);
955
+ if (
956
+ renderWasConcurrent &&
957
+ !isRenderConsistentWithExternalStores(finishedWork)
958
+ ) {
959
+ // A store was mutated in an interleaved event. Render again,
960
+ // synchronously, to block further mutations.
961
+ exitStatus = renderRootSync(root, lanes, false);
962
+ // We assume the tree is now consistent because we didn't yield to any
963
+ // concurrent events.
964
+ renderWasConcurrent = false;
965
+ // Need to check the exit status again.
966
+ continue;
967
+ }
968
+
969
+ // Check if something threw
970
+ if (
971
+ (disableLegacyMode || root.tag !== LegacyRoot) &&
972
+ exitStatus === RootErrored
973
+ ) {
974
+ const lanesThatJustErrored = lanes;
975
+ const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
976
root,
916
- lanes,
917
- NoLane,
918
- workInProgressRootDidSkipSuspendedSiblings,
977
+ lanesThatJustErrored,
978
);
920
- } else {
921
- // The render completed.
922
-
923
- // Check if this render may have yielded to a concurrent event, and if so,
924
- // confirm that any newly rendered stores are consistent.
925
- // TODO: It's possible that even a concurrent render may never have yielded
926
- // to the main thread, if it was fast enough, or if it expired. We could
927
- // skip the consistency check in that case, too.
928
- const finishedWork: Fiber = (root.current.alternate: any);
929
- if (
930
- renderWasConcurrent &&
931
- !isRenderConsistentWithExternalStores(finishedWork)
932
- ) {
933
- // A store was mutated in an interleaved event. Render again,
934
- // synchronously, to block further mutations.
935
- exitStatus = renderRootSync(root, lanes);
936
- // We assume the tree is now consistent because we didn't yield to any
937
- // concurrent events.
938
- renderWasConcurrent = false;
939
- // Need to check the exit status again.
940
- continue;
941
- }
942
-
943
- // Check if something threw
944
- if (
945
- (disableLegacyMode || root.tag !== LegacyRoot) &&
946
- exitStatus === RootErrored
947
- ) {
948
- const lanesThatJustErrored = lanes;
949
- const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
979
+ if (errorRetryLanes !== NoLanes) {
980
+ lanes = errorRetryLanes;
981
+ exitStatus = recoverFromConcurrentError(
982
root,
983
lanesThatJustErrored,
984
+ errorRetryLanes,
985
);
953
- if (errorRetryLanes !== NoLanes) {
954
- lanes = errorRetryLanes;
955
- exitStatus = recoverFromConcurrentError(
956
- root,
957
- lanesThatJustErrored,
958
- errorRetryLanes,
959
- );
960
- renderWasConcurrent = false;
961
- // Need to check the exit status again.
962
- if (exitStatus !== RootErrored) {
963
- // The root did not error this time. Restart the exit algorithm
964
- // from the beginning.
965
- // TODO: Refactor the exit algorithm to be less confusing. Maybe
966
- // more branches + recursion instead of a loop. I think the only
967
- // thing that causes it to be a loop is the RootDidNotComplete
968
- // check. If that's true, then we don't need a loop/recursion
969
- // at all.
970
- continue;
971
- } else {
972
- // The root errored yet again. Proceed to commit the tree.
973
- }
986
+ renderWasConcurrent = false;
987
+ // Need to check the exit status again.
988
+ if (exitStatus !== RootErrored) {
989
+ // The root did not error this time. Restart the exit algorithm
990
+ // from the beginning.
991
+ // TODO: Refactor the exit algorithm to be less confusing. Maybe
992
+ // more branches + recursion instead of a loop. I think the only
993
+ // thing that causes it to be a loop is the RootDidNotComplete
994
+ // check. If that's true, then we don't need a loop/recursion
995
+ // at all.
996
+ continue;
997
+ } else {
998
+ // The root errored yet again. Proceed to commit the tree.
999
}
1000
}
976
- if (exitStatus === RootFatalErrored) {
977
- prepareFreshStack(root, NoLanes);
978
- markRootSuspended(
979
- root,
980
- lanes,
981
- NoLane,
982
- workInProgressRootDidSkipSuspendedSiblings,
983
- );
984
- break;
985
- }
986
-
987
- // We now have a consistent tree. The next step is either to commit it,
988
- // or, if something suspended, wait to commit it after a timeout.
989
- finishConcurrentRender(root, exitStatus, finishedWork, lanes);
1001
}
991
- break;
992
- } while (true);
993
- }
1002
+ if (exitStatus === RootFatalErrored) {
1003
+ prepareFreshStack(root, NoLanes);
1004
+ // Since this is a fatal error, we're going to pretend we attempted
1005
+ // the entire tree, to avoid scheduling a prerender.
1006
+ const didAttemptEntireTree = true;
1007
+ markRootSuspended(root, lanes, NoLane, didAttemptEntireTree);
1008
+ break;
1009
+ }
1010
+
1011
+ // We now have a consistent tree. The next step is either to commit it,
1012
+ // or, if something suspended, wait to commit it after a timeout.
1013
+ finishConcurrentRender(root, exitStatus, finishedWork, lanes);
1014
+ }
1015
+ break;
1016
+ } while (true);
1017
1018
ensureRootIsScheduled(root);
1019
}
@@ -1023,7 +1046,7 @@ function recoverFromConcurrentError(
1046
rootWorkInProgress.flags |= ForceClientRender;
1047
}
1048
1026
- const exitStatus = renderRootSync(root, errorRetryLanes);
1049
+ const exitStatus = renderRootSync(root, errorRetryLanes, false);
1050
if (exitStatus !== RootErrored) {
1051
// Successfully finished rendering on retry
1052
@@ -1107,11 +1130,13 @@ function finishConcurrentRender(
1130
// This is a transition, so we should exit without committing a
1131
// placeholder and without scheduling a timeout. Delay indefinitely
1132
// until we receive more data.
1133
+ const didAttemptEntireTree =
1134
+ !workInProgressRootDidSkipSuspendedSiblings;
1135
markRootSuspended(
1136
root,
1137
lanes,
1138
workInProgressDeferredLane,
1114
- workInProgressRootDidSkipSuspendedSiblings,
1139
+ didAttemptEntireTree,
1140
);
1141
return;
1142
}
@@ -1167,11 +1192,13 @@ function finishConcurrentRender(
1192
1193
// Don't bother with a very short suspense time.
1194
if (msUntilTimeout > 10) {
1195
+ const didAttemptEntireTree =
1196
+ !workInProgressRootDidSkipSuspendedSiblings;
1197
markRootSuspended(
1198
root,
1199
lanes,
1200
workInProgressDeferredLane,
1174
- workInProgressRootDidSkipSuspendedSiblings,
1201
+ didAttemptEntireTree,
1202
);
1203
1204
const nextLanes = getNextLanes(root, NoLanes);
@@ -1285,7 +1312,8 @@ function commitRootWhenReady(
1312
completedRenderEndTime,
1313
),
1314
);
1288
- markRootSuspended(root, lanes, spawnedLane, didSkipSuspendedSiblings);
1315
+ const didAttemptEntireTree = !didSkipSuspendedSiblings;
1316
+ markRootSuspended(root, lanes, spawnedLane, didAttemptEntireTree);
1317
return;
1318
}
1319
}
@@ -1408,7 +1436,7 @@ function markRootSuspended(
1436
root: FiberRoot,
1437
suspendedLanes: Lanes,
1438
spawnedLane: Lane,
1411
- didSkipSuspendedSiblings: boolean,
1439
+ didAttemptEntireTree: boolean,
1440
) {
1441
// When suspending, we should always exclude lanes that were pinged or (more
1442
// rarely, since we try to avoid it) updated during the render phase.
@@ -1417,12 +1445,7 @@ function markRootSuspended(
1445
suspendedLanes,
1446
workInProgressRootInterleavedUpdatedLanes,
1447
);
1420
- _markRootSuspended(
1421
- root,
1422
- suspendedLanes,
1423
- spawnedLane,
1424
- didSkipSuspendedSiblings,
1425
- );
1448
+ _markRootSuspended(root, suspendedLanes, spawnedLane, didAttemptEntireTree);
1449
}
1450
1451
export function flushRoot(root: FiberRoot, lanes: Lanes) {
@@ -1964,7 +1987,12 @@ export function renderDidSuspendDelayIfPossible(): void {
1987
1988
if (
1989
!workInProgressRootDidSkipSuspendedSiblings &&
1967
- !includesBlockingLane(workInProgressRootRenderLanes)
1990
+ // Check if the root will be blocked from committing.
1991
+ // TODO: Consider aligning this better with the rest of the logic. Maybe
1992
+ // we should only set the exit status to RootSuspendedWithDelay if this
1993
+ // condition is true? And remove the equivalent checks elsewhere.
1994
+ (includesOnlyTransitions(workInProgressRootRenderLanes) ||
1995
+ getSuspenseHandler() === null)
1996
) {
1997
// This render may not have originally been scheduled as a prerender, but
1998
// something suspended inside the visible part of the tree, which means we
@@ -1990,11 +2018,12 @@ export function renderDidSuspendDelayIfPossible(): void {
2018
// pinged or updated while we were rendering.
2019
// TODO: Consider unwinding immediately, using the
2020
// SuspendedOnHydration mechanism.
2021
+ const didAttemptEntireTree = false;
2022
markRootSuspended(
2023
workInProgressRoot,
2024
workInProgressRootRenderLanes,
2025
workInProgressDeferredLane,
1997
- workInProgressRootDidSkipSuspendedSiblings,
2026
+ didAttemptEntireTree,
2027
);
2028
}
2029
}
@@ -2024,7 +2053,11 @@ export function renderHasNotSuspendedYet(): boolean {
2053
// TODO: Over time, this function and renderRootConcurrent have become more
2054
// and more similar. Not sure it makes sense to maintain forked paths. Consider
2055
// unifying them again.
2027
-function renderRootSync(root: FiberRoot, lanes: Lanes) {
2056
+function renderRootSync(
2057
+ root: FiberRoot,
2058
+ lanes: Lanes,
2059
+ shouldYieldForPrerendering: boolean,
2060
+): RootExitStatus {
2061
const prevExecutionContext = executionContext;
2062
executionContext |= RenderContext;
2063
const prevDispatcher = pushDispatcher(root.containerInfo);
@@ -2064,6 +2097,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2097
}
2098
2099
let didSuspendInShell = false;
2100
+ let exitStatus = workInProgressRootExitStatus;
2101
outer: do {
2102
try {
2103
if (
@@ -2085,16 +2119,37 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2119
// Selective hydration. An update flowed into a dehydrated tree.
2120
// Interrupt the current render so the work loop can switch to the
2121
// hydration lane.
2122
+ // TODO: I think we might not need to reset the stack here; we can
2123
+ // just yield and reset the stack when we re-enter the work loop,
2124
+ // like normal.
2125
resetWorkInProgressStack();
2089
- workInProgressRootExitStatus = RootDidNotComplete;
2126
+ exitStatus = RootDidNotComplete;
2127
break outer;
2128
}
2129
case SuspendedOnImmediate:
2093
- case SuspendedOnData: {
2094
- if (!didSuspendInShell && getSuspenseHandler() === null) {
2130
+ case SuspendedOnData:
2131
+ case SuspendedOnDeprecatedThrowPromise: {
2132
+ if (getSuspenseHandler() === null) {
2133
didSuspendInShell = true;
2134
}
2097
- // Intentional fallthrough
2135
+ const reason = workInProgressSuspendedReason;
2136
+ workInProgressSuspendedReason = NotSuspended;
2137
+ workInProgressThrownValue = null;
2138
+ throwAndUnwindWorkLoop(root, unitOfWork, thrownValue, reason);
2139
+ if (
2140
+ enableSiblingPrerendering &&
2141
+ shouldYieldForPrerendering &&
2142
+ workInProgressRootIsPrerendering
2143
+ ) {
2144
+ // We've switched into prerendering mode. This implies that we
2145
+ // suspended outside of a Suspense boundary, which means this
2146
+ // render will be blocked from committing. Yield to the main
2147
+ // thread so we can switch to prerendering using the concurrent
2148
+ // work loop.
2149
+ exitStatus = RootInProgress;
2150
+ break outer;
2151
+ }
2152
+ break;
2153
}
2154
default: {
2155
// Unwind then continue with the normal work loop.
@@ -2107,6 +2162,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2162
}
2163
}
2164
workLoopSync();
2165
+ exitStatus = workInProgressRootExitStatus;
2166
break;
2167
} catch (thrownValue) {
2168
handleThrow(root, thrownValue);
@@ -2129,14 +2185,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2185
popDispatcher(prevDispatcher);
2186
popAsyncDispatcher(prevAsyncDispatcher);
2187
2132
- if (workInProgress !== null) {
2133
- // This is a sync render, so we should have finished the whole tree.
2134
- throw new Error(
2135
- 'Cannot commit an incomplete root. This error is likely caused by a ' +
2136
- 'bug in React. Please file an issue.',
2137
- );
2138
- }
2139
-
2188
if (__DEV__) {
2189
if (enableDebugTracing) {
2190
logRenderStopped();
@@ -2147,14 +2195,21 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2195
markRenderStopped();
2196
}
2197
2150
- // Set this to null to indicate there's no in-progress render.
2151
- workInProgressRoot = null;
2152
- workInProgressRootRenderLanes = NoLanes;
2198
+ if (workInProgress !== null) {
2199
+ // Did not complete the tree. This can happen if something suspended in
2200
+ // the shell.
2201
+ } else {
2202
+ // Normal case. We completed the whole tree.
2203
+
2204
+ // Set this to null to indicate there's no in-progress render.
2205
+ workInProgressRoot = null;
2206
+ workInProgressRootRenderLanes = NoLanes;
2207
2154
- // It's safe to process the queue now that the render phase is complete.
2155
- finishQueueingConcurrentUpdates();
2208
+ // It's safe to process the queue now that the render phase is complete.
2209
+ finishQueueingConcurrentUpdates();
2210
+ }
2211
2157
- return workInProgressRootExitStatus;
2212
+ return exitStatus;
2213
}
2214
2215
// The work loop is an extremely hot path. Tell Closure not to inline it.
@@ -2200,9 +2255,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2255
//
2256
// If we were previously in prerendering mode, check if we received any new
2257
// data during an interleaved event.
2203
- if (workInProgressRootIsPrerendering) {
2204
- workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
2205
- }
2258
+ workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes);
2259
}
2260
2261
if (__DEV__) {
@@ -3744,6 +3797,9 @@ function pingSuspendedRoot(
3797
// the logic of whether or not a root suspends once it completes.
3798
// TODO: If we're rendering sync either due to Sync, Batched or expired,
3799
// we should probably never restart.
3800
+ // TODO: Attach different listeners depending on whether the listener was
3801
+ // attached during prerendering. Prerender pings should not interrupt
3802
+ // normal renders.
3803
3804
// If we're suspended with delay, or if it's a retry, we'll always suspend
3805
// so we can always restart.
packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+12
@@ -420,6 +420,10 @@ describe('ReactDeferredValue', () => {
420
// The initial value suspended, so we attempt the final value, which
421
// also suspends.
422
'Suspend! [Final]',
423
+
424
+ ...(gate('enableSiblingPrerendering')
425
+ ? ['Suspend! [Loading...]', 'Suspend! [Final]']
426
+ : []),
427
]);
428
expect(root).toMatchRenderedOutput(null);
429
@@ -459,6 +463,10 @@ describe('ReactDeferredValue', () => {
463
// The initial value suspended, so we attempt the final value, which
464
// also suspends.
465
'Suspend! [Final]',
466
+
467
+ ...(gate('enableSiblingPrerendering')
468
+ ? ['Suspend! [Loading...]', 'Suspend! [Final]']
469
+ : []),
470
]);
471
expect(root).toMatchRenderedOutput(null);
472
@@ -533,6 +541,10 @@ describe('ReactDeferredValue', () => {
541
// The initial value suspended, so we attempt the final value, which
542
// also suspends.
543
'Suspend! [Final]',
544
+
545
+ ...(gate('enableSiblingPrerendering')
546
+ ? ['Suspend! [Loading...]', 'Suspend! [Final]']
547
+ : []),
548
]);
549
expect(root).toMatchRenderedOutput(null);
550
packages/react-reconciler/src/__tests__/ReactSiblingPrerendering-test.js
+71
@@ -479,4 +479,75 @@ describe('ReactSiblingPrerendering', () => {
479
assertLog([]);
480
},
481
);
482
+
483
+ it(
484
+ 'when a synchronous update suspends outside a boundary, the resulting' +
485
+ 'prerender is concurrent',
486
+ async () => {
487
+ function App() {
488
+ return (
489
+ <>
490
+ <Text text="A" />
491
+ <Text text="B" />
492
+ <AsyncText text="Async" />
493
+ <Text text="C" />
494
+ <Text text="D" />
495
+ </>
496
+ );
497
+ }
498
+
499
+ const root = ReactNoop.createRoot();
500
+ // Mount the root synchronously
501
+ ReactNoop.flushSync(() => root.render(<App />));
502
+
503
+ // Synchronously render everything until we suspend in the shell
504
+ assertLog(['A', 'B', 'Suspend! [Async]']);
505
+
506
+ if (gate('enableSiblingPrerendering')) {
507
+ // The rest of the siblings begin to prerender concurrently. Notice
508
+ // that we don't unwind here; we pick up where we left off above.
509
+ await waitFor(['C']);
510
+ await waitFor(['D']);
511
+ }
512
+
513
+ assertLog([]);
514
+ expect(root).toMatchRenderedOutput(null);
515
+
516
+ await resolveText('Async');
517
+ assertLog(['A', 'B', 'Async', 'C', 'D']);
518
+ expect(root).toMatchRenderedOutput('ABAsyncCD');
519
+ },
520
+ );
521
+
522
+ it('restart a suspended sync render if something suspends while prerendering the siblings', async () => {
523
+ function App() {
524
+ return (
525
+ <>
526
+ <Text text="A" />
527
+ <Text text="B" />
528
+ <AsyncText text="Async" />
529
+ <Text text="C" />
530
+ <Text text="D" />
531
+ </>
532
+ );
533
+ }
534
+
535
+ const root = ReactNoop.createRoot();
536
+ // Mount the root synchronously
537
+ ReactNoop.flushSync(() => root.render(<App />));
538
+
539
+ // Synchronously render everything until we suspend in the shell
540
+ assertLog(['A', 'B', 'Suspend! [Async]']);
541
+
542
+ if (gate('enableSiblingPrerendering')) {
543
+ // The rest of the siblings begin to prerender concurrently
544
+ await waitFor(['C']);
545
+ }
546
+
547
+ // While we're prerendering, Async resolves. We should unwind and
548
+ // start over, rather than continue prerendering D.
549
+ await resolveText('Async');
550
+ assertLog(['A', 'B', 'Async', 'C', 'D']);
551
+ expect(root).toMatchRenderedOutput('ABAsyncCD');
552
+ });
553
});
packages/react-reconciler/src/__tests__/useSyncExternalStore-test.js
+147
-1
@@ -24,6 +24,9 @@ let startTransition;
24
let waitFor;
25
let waitForAll;
26
let assertLog;
27
+let Suspense;
28
+let useMemo;
29
+let textCache;
30
31
// This tests the native useSyncExternalStore implementation, not the shim.
32
// Tests that apply to both the native implementation and the shim should go
@@ -45,7 +48,9 @@ describe('useSyncExternalStore', () => {
48
use = React.use;
49
useSyncExternalStore = React.useSyncExternalStore;
50
startTransition = React.startTransition;
48
-
51
+ Suspense = React.Suspense;
52
+ useMemo = React.useMemo;
53
+ textCache = new Map();
54
const InternalTestUtils = require('internal-test-utils');
55
waitFor = InternalTestUtils.waitFor;
56
waitForAll = InternalTestUtils.waitForAll;
@@ -54,6 +59,60 @@ describe('useSyncExternalStore', () => {
59
act = require('internal-test-utils').act;
60
});
61
62
+ function resolveText(text) {
63
+ const record = textCache.get(text);
64
+ if (record === undefined) {
65
+ const newRecord = {
66
+ status: 'resolved',
67
+ value: text,
68
+ };
69
+ textCache.set(text, newRecord);
70
+ } else if (record.status === 'pending') {
71
+ const thenable = record.value;
72
+ record.status = 'resolved';
73
+ record.value = text;
74
+ thenable.pings.forEach(t => t());
75
+ }
76
+ }
77
+ function readText(text) {
78
+ const record = textCache.get(text);
79
+ if (record !== undefined) {
80
+ switch (record.status) {
81
+ case 'pending':
82
+ throw record.value;
83
+ case 'rejected':
84
+ throw record.value;
85
+ case 'resolved':
86
+ return record.value;
87
+ }
88
+ } else {
89
+ const thenable = {
90
+ pings: [],
91
+ then(resolve) {
92
+ if (newRecord.status === 'pending') {
93
+ thenable.pings.push(resolve);
94
+ } else {
95
+ Promise.resolve().then(() => resolve(newRecord.value));
96
+ }
97
+ },
98
+ };
99
+
100
+ const newRecord = {
101
+ status: 'pending',
102
+ value: thenable,
103
+ };
104
+ textCache.set(text, newRecord);
105
+
106
+ throw thenable;
107
+ }
108
+ }
109
+
110
+ function AsyncText({text}) {
111
+ const result = readText(text);
112
+ Scheduler.log(text);
113
+ return result;
114
+ }
115
+
116
function Text({text}) {
117
Scheduler.log(text);
118
return text;
@@ -292,4 +351,91 @@ describe('useSyncExternalStore', () => {
351
);
352
},
353
);
354
+
355
+ it('regression: does not infinite loop for only changing store reference in render', async () => {
356
+ let store = {value: {}};
357
+ let listeners = [];
358
+
359
+ const ExternalStore = {
360
+ set(value) {
361
+ // Change the store ref, but not the value.
362
+ // This will cause a new snapshot to be returned if set is called in render,
363
+ // but the value is the same. Stores should not do this, but if they do
364
+ // we shouldn't infinitely render.
365
+ store = {...store};
366
+ setTimeout(() => {
367
+ store = {value};
368
+ emitChange();
369
+ }, 100);
370
+ emitChange();
371
+ },
372
+ subscribe(listener) {
373
+ listeners = [...listeners, listener];
374
+ return () => {
375
+ listeners = listeners.filter(l => l !== listener);
376
+ };
377
+ },
378
+ getSnapshot() {
379
+ return store;
380
+ },
381
+ };
382
+
383
+ function emitChange() {
384
+ listeners.forEach(l => l());
385
+ }
386
+
387
+ function StoreText() {
388
+ const {value} = useSyncExternalStore(
389
+ ExternalStore.subscribe,
390
+ ExternalStore.getSnapshot,
391
+ );
392
+
393
+ useMemo(() => {
394
+ // Set the store value on mount.
395
+ // This breaks the rules of React, but should be handled gracefully.
396
+ const newValue = {text: 'B'};
397
+ if (value == null || newValue !== value) {
398
+ ExternalStore.set(newValue);
399
+ }
400
+ }, []);
401
+
402
+ return <Text text={value.text || '(not set)'} />;
403
+ }
404
+
405
+ function App() {
406
+ return (
407
+ <>
408
+ <Suspense fallback={'Loading...'}>
409
+ <AsyncText text={'A'} />
410
+ <StoreText />
411
+ </Suspense>
412
+ </>
413
+ );
414
+ }
415
+
416
+ const root = ReactNoop.createRoot();
417
+
418
+ // The initial render suspends.
419
+ await act(async () => {
420
+ root.render(<App />);
421
+ });
422
+ assertLog([...(gate('enableSiblingPrerendering') ? ['(not set)'] : [])]);
423
+
424
+ expect(root).toMatchRenderedOutput('Loading...');
425
+
426
+ // Resolve the data and finish rendering.
427
+ // When resolving, the store should not get stuck in an infinite loop.
428
+ await act(() => {
429
+ resolveText('A');
430
+ });
431
+ assertLog([
432
+ ...(gate('enableSiblingPrerendering')
433
+ ? ['A', 'B', 'A', 'B', 'B']
434
+ : gate(flags => flags.alwaysThrottleRetries)
435
+ ? ['A', '(not set)', 'A', '(not set)', 'B']
436
+ : ['A', '(not set)', 'A', '(not set)', '(not set)', 'B']),
437
+ ]);
438
+
439
+ expect(root).toMatchRenderedOutput('AB');
440
+ });
441
});