@samitouri / QOS-React-2 / commits / 989af12f72

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