Skip to content

Commit

Permalink
Client render dehydrated Suspense boundaries on document load
Browse files Browse the repository at this point in the history
When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state.

Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading.

The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires.

To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states.

This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remenbering this subtle detail every time you create a test that asserts Suspense resolution order.
  • Loading branch information
gnoff committed Nov 23, 2024
1 parent aba370f commit 0df3dc3
Show file tree
Hide file tree
Showing 10 changed files with 445 additions and 224 deletions.
20 changes: 20 additions & 0 deletions packages/internal-test-utils/ReactJSDOM.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const JSDOMModule = jest.requireActual('jsdom');

const OriginalJSDOM = JSDOMModule.JSDOM;

module.exports = JSDOMModule;
module.exports.JSDOM = function JSDOM() {
let result;
if (new.target) {
result = Reflect.construct(OriginalJSDOM, arguments);
} else {
result = JSDOM.apply(undefined, arguments);
}

require('./ReactJSDOMUtils').setupDocumentReadyState(
result.window.document,
result.window.Event,
);

return result;
};
33 changes: 33 additions & 0 deletions packages/internal-test-utils/ReactJSDOMUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export function setupDocumentReadyState(
document: Document,
Event: typeof Event,
) {
let readyState: 0 | 1 | 2 = 0;
Object.defineProperty(document, 'readyState', {
get() {
switch (readyState) {
case 0:
return 'loading';
case 1:
return 'interactive';
case 2:
return 'complete';
}
},
set(value) {
if (value === 'interactive' && readyState < 1) {
readyState = 1;
document.dispatchEvent(new Event('readystatechange'));
} else if (value === 'complete' && readyState < 2) {
readyState = 2;
document.dispatchEvent(new Event('readystatechange'));
document.dispatchEvent(new Event('DOMContentLoaded'));
} else if (value === 'loading') {
// We allow resetting the readyState to loading mostly for pragamtism.
// tests that use this environment don't reset the document between tests.
readyState = 0;
}
},
configurable: true,
});
}
22 changes: 21 additions & 1 deletion packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
const FORM_STATE_IS_MATCHING = 'F!';
const FORM_STATE_IS_NOT_MATCHING = 'F';

const DOCUMENT_READY_STATE_COMPLETE = 'complete';

const STYLE = 'style';

opaque type HostContextNamespace = 0 | 1 | 2;
Expand Down Expand Up @@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
export function isSuspenseInstanceFallback(
instance: SuspenseInstance,
): boolean {
return instance.data === SUSPENSE_FALLBACK_START_DATA;
return (
instance.data === SUSPENSE_FALLBACK_START_DATA ||
(instance.data === SUSPENSE_PENDING_START_DATA &&
instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE)
);
}

export function getSuspenseInstanceFallbackErrorDetails(
Expand Down Expand Up @@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
callback: () => void,
) {
const ownerDocument = instance.ownerDocument;
if (ownerDocument.readyState !== DOCUMENT_READY_STATE_COMPLETE) {
ownerDocument.addEventListener(
'DOMContentLoaded',
() => {
if (instance.data === SUSPENSE_PENDING_START_DATA) {
callback();
}
},
{
once: true,
},
);
}
instance._reactRetry = callback;
}

Expand Down
Loading

0 comments on commit 0df3dc3

Please sign in to comment.