@samitouri / QOS-React / commits / de68d2f4a2

Register Suspense retry handlers in commit phase (#31667)

To avoid GC pressure and accidentally hanging onto old trees Suspense boundary retries are now implemented in the commit phase. I used the Callback flag which was previously only used to schedule callbacks for Class components. This isn't quite semantically equivalent but it's unused and seemingly compatible.

Josh Story committed Dec 4, 2024 at 07:58 UTC de68d2f4a2403ad1ef46a3036ddc1f9080640588
3 files changed +43 -19
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+21 -13
@@ -1310,20 +1310,28 @@ export function registerSuspenseInstanceRetry(
1310 callback: () => void,
1311 ) {
1312 const ownerDocument = instance.ownerDocument;
1313 - if (ownerDocument.readyState !== DOCUMENT_READY_STATE_COMPLETE) {
1314 - ownerDocument.addEventListener(
1315 - 'DOMContentLoaded',
1316 - () => {
1317 - if (instance.data === SUSPENSE_PENDING_START_DATA) {
1318 - callback();
1319 - }
1320 - },
1321 - {
1322 - once: true,
1323 - },
1324 - );
1313 + if (
1314 + // The Fizz runtime must have put this boundary into client render or complete
1315 + // state after the render finished but before it committed. We need to call the
1316 + // callback now rather than wait
1317 + instance.data !== SUSPENSE_PENDING_START_DATA ||
1318 + // The boundary is still in pending status but the document has finished loading
1319 + // before we could register the event handler that would have scheduled the retry
1320 + // on load so we call teh callback now.
1321 + ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE
1322 + ) {
1323 + callback();
1324 + } else {
1325 + // We're still in pending status and the document is still loading so we attach
1326 + // a listener to the document load even and expose the retry on the instance for
1327 + // the Fizz runtime to trigger if it ends up resolving this boundary
1328 + const listener = () => {
1329 + callback();
1330 + ownerDocument.removeEventListener('DOMContentLoaded', listener);
1331 + };
1332 + ownerDocument.addEventListener('DOMContentLoaded', listener);
1333 + instance._reactRetry = listener;
1334 }
1326 - instance._reactRetry = callback;
1335 }
1336
1337 export function canHydrateFormStateMarker(
packages/react-reconciler/src/ReactFiberBeginWork.js
+3 -6
@@ -79,6 +79,7 @@ import {
79 PerformedWork,
80 Placement,
81 Hydrating,
82 + Callback,
83 ContentReset,
84 DidCapture,
85 Update,
@@ -166,7 +167,6 @@ import {
167 isSuspenseInstancePending,
168 isSuspenseInstanceFallback,
169 getSuspenseInstanceFallbackErrorDetails,
169 - registerSuspenseInstanceRetry,
170 supportsHydration,
171 supportsResources,
172 supportsSingletons,
@@ -256,7 +256,6 @@ import {
256 isFunctionClassComponent,
257 } from './ReactFiber';
258 import {
259 - retryDehydratedSuspenseBoundary,
259 scheduleUpdateOnFiber,
260 renderDidSuspendDelayIfPossible,
261 markSkippedUpdateLanes,
@@ -2816,12 +2815,10 @@ function updateDehydratedSuspenseComponent(
2815 // on the client than if we just leave it alone. If the server times out or errors
2816 // these should update this boundary to the permanent Fallback state instead.
2817 // Mark it as having captured (i.e. suspended).
2819 - workInProgress.flags |= DidCapture;
2818 + // Also Mark it as requiring retry.
2819 + workInProgress.flags |= DidCapture | Callback;
2820 // Leave the child in place. I.e. the dehydrated fragment.
2821 workInProgress.child = current.child;
2822 - // Register a callback to retry this boundary once the server has sent the result.
2823 - const retry = retryDehydratedSuspenseBoundary.bind(null, current);
2824 - registerSuspenseInstanceRetry(suspenseInstance, retry);
2822 return null;
2823 } else {
2824 // This is the first attempt.
packages/react-reconciler/src/ReactFiberCommitWork.js
+19
@@ -142,6 +142,7 @@ import {
142 suspendInstance,
143 suspendResource,
144 resetFormInstance,
145 + registerSuspenseInstanceRetry,
146 } from './ReactFiberConfig';
147 import {
148 captureCommitPhaseError,
@@ -154,6 +155,7 @@ import {
155 addMarkerProgressCallbackToPendingTransition,
156 addMarkerIncompleteCallbackToPendingTransition,
157 addMarkerCompleteCallbackToPendingTransition,
158 + retryDehydratedSuspenseBoundary,
159 } from './ReactFiberWorkLoop';
160 import {
161 HasEffect as HookHasEffect,
@@ -526,6 +528,23 @@ function commitLayoutEffectOnFiber(
528 if (flags & Update) {
529 commitSuspenseHydrationCallbacks(finishedRoot, finishedWork);
530 }
531 + if (flags & Callback) {
532 + // This Boundary is in fallback and has a dehydrated Suspense instance.
533 + // We could in theory assume the dehydrated state but we recheck it for
534 + // certainty.
535 + const finishedState: SuspenseState | null = finishedWork.memoizedState;
536 + if (finishedState !== null) {
537 + const dehydrated = finishedState.dehydrated;
538 + if (dehydrated !== null) {
539 + // Register a callback to retry this boundary once the server has sent the result.
540 + const retry = retryDehydratedSuspenseBoundary.bind(
541 + null,
542 + finishedWork,
543 + );
544 + registerSuspenseInstanceRetry(dehydrated, retry);
545 + }
546 + }
547 + }
548 break;
549 }
550 case OffscreenComponent: {