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: {