Skip to content

Commit

Permalink
Client render dehydrated Suspense boundaries on document load (#31620)
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 remembering this subtle detail
every time you create a test that asserts Suspense resolution order.

DiffTrain build for [16d2bbb](16d2bbb)
  • Loading branch information
gnoff committed Dec 3, 2024
1 parent 2241a5a commit 73566a7
Show file tree
Hide file tree
Showing 23 changed files with 654 additions and 534 deletions.
2 changes: 1 addition & 1 deletion compiled-rn/VERSION_NATIVE_FB
Original file line number Diff line number Diff line change
@@ -1 +1 @@
19.0.0-native-fb-e3b7ef32-20241122
19.0.0-native-fb-16d2bbbd-20241203
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<7cd589c2745bec9eac6a3fc0321072ad>>
* @generated SignedSource<<cbb4fb73c9e1ccc01854e808c548f830>>
*/

"use strict";
Expand Down Expand Up @@ -420,5 +420,5 @@ __DEV__ &&
exports.useFormStatus = function () {
return resolveDispatcher().useHostTransitionStatus();
};
exports.version = "19.0.0-native-fb-e3b7ef32-20241122";
exports.version = "19.0.0-native-fb-16d2bbbd-20241203";
})();
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<a41b51169bd4f2ef16672904251efb0a>>
* @generated SignedSource<<239a11226e90f84bbe038aeaaca66638>>
*/

"use strict";
Expand Down Expand Up @@ -203,4 +203,4 @@ exports.useFormState = function (action, initialState, permalink) {
exports.useFormStatus = function () {
return ReactSharedInternals.H.useHostTransitionStatus();
};
exports.version = "19.0.0-native-fb-e3b7ef32-20241122";
exports.version = "19.0.0-native-fb-16d2bbbd-20241203";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<a41b51169bd4f2ef16672904251efb0a>>
* @generated SignedSource<<239a11226e90f84bbe038aeaaca66638>>
*/

"use strict";
Expand Down Expand Up @@ -203,4 +203,4 @@ exports.useFormState = function (action, initialState, permalink) {
exports.useFormStatus = function () {
return ReactSharedInternals.H.useHostTransitionStatus();
};
exports.version = "19.0.0-native-fb-e3b7ef32-20241122";
exports.version = "19.0.0-native-fb-16d2bbbd-20241203";

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<b0d60e00d26566a40219d9d7d7496f2d>>
* @generated SignedSource<<f21aeede2360fa78702f7e00ba0891d5>>
*/

/*
Expand Down Expand Up @@ -5320,7 +5320,9 @@ function findFirstSuspended(row) {
if (
null !== state &&
((state = state.dehydrated),
null === state || "$?" === state.data || "$!" === state.data)
null === state ||
"$?" === state.data ||
isSuspenseInstanceFallback(state))
)
return node;
} else if (19 === node.tag && void 0 !== node.memoizedProps.revealOrder) {
Expand Down Expand Up @@ -6462,7 +6464,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
((nextInstance = nextInstance.dehydrated), null !== nextInstance)
)
return (
"$!" === nextInstance.data
isSuspenseInstanceFallback(nextInstance)
? (workInProgress.lanes = 16)
: (workInProgress.lanes = 536870912),
null
Expand Down Expand Up @@ -6572,7 +6574,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
(workInProgress = showFallback));
else if (
(pushPrimaryTreeSuspenseHandler(workInProgress),
"$!" === nextInstance.data)
isSuspenseInstanceFallback(nextInstance))
) {
JSCompiler_temp =
nextInstance.nextSibling && nextInstance.nextSibling.dataset;
Expand Down Expand Up @@ -6661,7 +6663,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
null,
current
)),
(nextInstance._reactRetry = workInProgress),
registerSuspenseInstanceRetry(nextInstance, workInProgress),
(workInProgress = null))
: ((renderLanes = JSCompiler_temp$jscomp$0.treeContext),
(nextHydratableInstance = getNextHydratable(
Expand Down Expand Up @@ -12350,20 +12352,20 @@ function extractEvents$1(
}
}
for (
var i$jscomp$inline_1492 = 0;
i$jscomp$inline_1492 < simpleEventPluginEvents.length;
i$jscomp$inline_1492++
var i$jscomp$inline_1489 = 0;
i$jscomp$inline_1489 < simpleEventPluginEvents.length;
i$jscomp$inline_1489++
) {
var eventName$jscomp$inline_1493 =
simpleEventPluginEvents[i$jscomp$inline_1492],
domEventName$jscomp$inline_1494 =
eventName$jscomp$inline_1493.toLowerCase(),
capitalizedEvent$jscomp$inline_1495 =
eventName$jscomp$inline_1493[0].toUpperCase() +
eventName$jscomp$inline_1493.slice(1);
var eventName$jscomp$inline_1490 =
simpleEventPluginEvents[i$jscomp$inline_1489],
domEventName$jscomp$inline_1491 =
eventName$jscomp$inline_1490.toLowerCase(),
capitalizedEvent$jscomp$inline_1492 =
eventName$jscomp$inline_1490[0].toUpperCase() +
eventName$jscomp$inline_1490.slice(1);
registerSimpleEvent(
domEventName$jscomp$inline_1494,
"on" + capitalizedEvent$jscomp$inline_1495
domEventName$jscomp$inline_1491,
"on" + capitalizedEvent$jscomp$inline_1492
);
}
registerSimpleEvent(ANIMATION_END, "onAnimationEnd");
Expand Down Expand Up @@ -14255,6 +14257,24 @@ function canHydrateTextInstance(instance, text, inRootOrSingleton) {
}
return instance;
}
function isSuspenseInstanceFallback(instance) {
return (
"$!" === instance.data ||
("$?" === instance.data && "complete" === instance.ownerDocument.readyState)
);
}
function registerSuspenseInstanceRetry(instance, callback) {
var ownerDocument = instance.ownerDocument;
"complete" !== ownerDocument.readyState &&
ownerDocument.addEventListener(
"DOMContentLoaded",
function () {
"$?" === instance.data && callback();
},
{ once: !0 }
);
instance._reactRetry = callback;
}
function getNextHydratable(node) {
for (; null != node; node = node.nextSibling) {
var nodeType = node.nodeType;
Expand Down Expand Up @@ -15836,16 +15856,16 @@ ReactDOMHydrationRoot.prototype.unstable_scheduleHydration = function (target) {
0 === i && attemptExplicitHydrationTarget(target);
}
};
var isomorphicReactPackageVersion$jscomp$inline_1735 = React.version;
var isomorphicReactPackageVersion$jscomp$inline_1732 = React.version;
if (
"19.0.0-native-fb-e3b7ef32-20241122" !==
isomorphicReactPackageVersion$jscomp$inline_1735
"19.0.0-native-fb-16d2bbbd-20241203" !==
isomorphicReactPackageVersion$jscomp$inline_1732
)
throw Error(
formatProdErrorMessage(
527,
isomorphicReactPackageVersion$jscomp$inline_1735,
"19.0.0-native-fb-e3b7ef32-20241122"
isomorphicReactPackageVersion$jscomp$inline_1732,
"19.0.0-native-fb-16d2bbbd-20241203"
)
);
ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
Expand All @@ -15865,25 +15885,25 @@ ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
null === componentOrElement ? null : componentOrElement.stateNode;
return componentOrElement;
};
var internals$jscomp$inline_2193 = {
var internals$jscomp$inline_2187 = {
bundleType: 0,
version: "19.0.0-native-fb-e3b7ef32-20241122",
version: "19.0.0-native-fb-16d2bbbd-20241203",
rendererPackageName: "react-dom",
currentDispatcherRef: ReactSharedInternals,
findFiberByHostInstance: getClosestInstanceFromNode,
reconcilerVersion: "19.0.0-native-fb-e3b7ef32-20241122"
reconcilerVersion: "19.0.0-native-fb-16d2bbbd-20241203"
};
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
var hook$jscomp$inline_2194 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
var hook$jscomp$inline_2188 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
if (
!hook$jscomp$inline_2194.isDisabled &&
hook$jscomp$inline_2194.supportsFiber
!hook$jscomp$inline_2188.isDisabled &&
hook$jscomp$inline_2188.supportsFiber
)
try {
(rendererID = hook$jscomp$inline_2194.inject(
internals$jscomp$inline_2193
(rendererID = hook$jscomp$inline_2188.inject(
internals$jscomp$inline_2187
)),
(injectedHook = hook$jscomp$inline_2194);
(injectedHook = hook$jscomp$inline_2188);
} catch (err) {}
}
exports.createRoot = function (container, options) {
Expand Down Expand Up @@ -15975,4 +15995,4 @@ exports.hydrateRoot = function (container, initialChildren, options) {
listenToAllSupportedEvents(container);
return new ReactDOMHydrationRoot(initialChildren);
};
exports.version = "19.0.0-native-fb-e3b7ef32-20241122";
exports.version = "19.0.0-native-fb-16d2bbbd-20241203";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @noflow
* @nolint
* @preventMunge
* @generated SignedSource<<2641c8c71c20c73c32860ce6c6092e1d>>
* @generated SignedSource<<0ebd78a0cf049a802a2b35960edaa452>>
*/

/*
Expand Down Expand Up @@ -5472,7 +5472,9 @@ function findFirstSuspended(row) {
if (
null !== state &&
((state = state.dehydrated),
null === state || "$?" === state.data || "$!" === state.data)
null === state ||
"$?" === state.data ||
isSuspenseInstanceFallback(state))
)
return node;
} else if (19 === node.tag && void 0 !== node.memoizedProps.revealOrder) {
Expand Down Expand Up @@ -6633,7 +6635,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
((nextInstance = nextInstance.dehydrated), null !== nextInstance)
)
return (
"$!" === nextInstance.data
isSuspenseInstanceFallback(nextInstance)
? (workInProgress.lanes = 16)
: (workInProgress.lanes = 536870912),
null
Expand Down Expand Up @@ -6743,7 +6745,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
(workInProgress = showFallback));
else if (
(pushPrimaryTreeSuspenseHandler(workInProgress),
"$!" === nextInstance.data)
isSuspenseInstanceFallback(nextInstance))
) {
JSCompiler_temp =
nextInstance.nextSibling && nextInstance.nextSibling.dataset;
Expand Down Expand Up @@ -6832,7 +6834,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
null,
current
)),
(nextInstance._reactRetry = workInProgress),
registerSuspenseInstanceRetry(nextInstance, workInProgress),
(workInProgress = null))
: ((renderLanes = JSCompiler_temp$jscomp$0.treeContext),
(nextHydratableInstance = getNextHydratable(
Expand Down Expand Up @@ -12995,20 +12997,20 @@ function extractEvents$1(
}
}
for (
var i$jscomp$inline_1580 = 0;
i$jscomp$inline_1580 < simpleEventPluginEvents.length;
i$jscomp$inline_1580++
var i$jscomp$inline_1577 = 0;
i$jscomp$inline_1577 < simpleEventPluginEvents.length;
i$jscomp$inline_1577++
) {
var eventName$jscomp$inline_1581 =
simpleEventPluginEvents[i$jscomp$inline_1580],
domEventName$jscomp$inline_1582 =
eventName$jscomp$inline_1581.toLowerCase(),
capitalizedEvent$jscomp$inline_1583 =
eventName$jscomp$inline_1581[0].toUpperCase() +
eventName$jscomp$inline_1581.slice(1);
var eventName$jscomp$inline_1578 =
simpleEventPluginEvents[i$jscomp$inline_1577],
domEventName$jscomp$inline_1579 =
eventName$jscomp$inline_1578.toLowerCase(),
capitalizedEvent$jscomp$inline_1580 =
eventName$jscomp$inline_1578[0].toUpperCase() +
eventName$jscomp$inline_1578.slice(1);
registerSimpleEvent(
domEventName$jscomp$inline_1582,
"on" + capitalizedEvent$jscomp$inline_1583
domEventName$jscomp$inline_1579,
"on" + capitalizedEvent$jscomp$inline_1580
);
}
registerSimpleEvent(ANIMATION_END, "onAnimationEnd");
Expand Down Expand Up @@ -14900,6 +14902,24 @@ function canHydrateTextInstance(instance, text, inRootOrSingleton) {
}
return instance;
}
function isSuspenseInstanceFallback(instance) {
return (
"$!" === instance.data ||
("$?" === instance.data && "complete" === instance.ownerDocument.readyState)
);
}
function registerSuspenseInstanceRetry(instance, callback) {
var ownerDocument = instance.ownerDocument;
"complete" !== ownerDocument.readyState &&
ownerDocument.addEventListener(
"DOMContentLoaded",
function () {
"$?" === instance.data && callback();
},
{ once: !0 }
);
instance._reactRetry = callback;
}
function getNextHydratable(node) {
for (; null != node; node = node.nextSibling) {
var nodeType = node.nodeType;
Expand Down Expand Up @@ -16489,16 +16509,16 @@ ReactDOMHydrationRoot.prototype.unstable_scheduleHydration = function (target) {
0 === i && attemptExplicitHydrationTarget(target);
}
};
var isomorphicReactPackageVersion$jscomp$inline_1825 = React.version;
var isomorphicReactPackageVersion$jscomp$inline_1822 = React.version;
if (
"19.0.0-native-fb-e3b7ef32-20241122" !==
isomorphicReactPackageVersion$jscomp$inline_1825
"19.0.0-native-fb-16d2bbbd-20241203" !==
isomorphicReactPackageVersion$jscomp$inline_1822
)
throw Error(
formatProdErrorMessage(
527,
isomorphicReactPackageVersion$jscomp$inline_1825,
"19.0.0-native-fb-e3b7ef32-20241122"
isomorphicReactPackageVersion$jscomp$inline_1822,
"19.0.0-native-fb-16d2bbbd-20241203"
)
);
ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
Expand All @@ -16518,13 +16538,13 @@ ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
null === componentOrElement ? null : componentOrElement.stateNode;
return componentOrElement;
};
var internals$jscomp$inline_1832 = {
var internals$jscomp$inline_1829 = {
bundleType: 0,
version: "19.0.0-native-fb-e3b7ef32-20241122",
version: "19.0.0-native-fb-16d2bbbd-20241203",
rendererPackageName: "react-dom",
currentDispatcherRef: ReactSharedInternals,
findFiberByHostInstance: getClosestInstanceFromNode,
reconcilerVersion: "19.0.0-native-fb-e3b7ef32-20241122",
reconcilerVersion: "19.0.0-native-fb-16d2bbbd-20241203",
getLaneLabelMap: function () {
for (
var map = new Map(), lane = 1, index$292 = 0;
Expand All @@ -16542,16 +16562,16 @@ var internals$jscomp$inline_1832 = {
}
};
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
var hook$jscomp$inline_2245 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
var hook$jscomp$inline_2239 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
if (
!hook$jscomp$inline_2245.isDisabled &&
hook$jscomp$inline_2245.supportsFiber
!hook$jscomp$inline_2239.isDisabled &&
hook$jscomp$inline_2239.supportsFiber
)
try {
(rendererID = hook$jscomp$inline_2245.inject(
internals$jscomp$inline_1832
(rendererID = hook$jscomp$inline_2239.inject(
internals$jscomp$inline_1829
)),
(injectedHook = hook$jscomp$inline_2245);
(injectedHook = hook$jscomp$inline_2239);
} catch (err) {}
}
exports.createRoot = function (container, options) {
Expand Down Expand Up @@ -16643,4 +16663,4 @@ exports.hydrateRoot = function (container, initialChildren, options) {
listenToAllSupportedEvents(container);
return new ReactDOMHydrationRoot(initialChildren);
};
exports.version = "19.0.0-native-fb-e3b7ef32-20241122";
exports.version = "19.0.0-native-fb-16d2bbbd-20241203";
Loading

0 comments on commit 73566a7

Please sign in to comment.