Hide/unhide the content of dehydrated suspense boundaries if they resuspend (#32900)

Found this bug while working on Activity. There's a weird edge case when
a dehydrated Suspense boundary is a direct child of another Suspense
boundary which is hydrated but then it resuspends without forcing the
inner one to hydrate/delete.

It used to just leave that in place because hiding/unhiding didn't deal
with dehydrated fragments.

Not sure this is really worth fixing.
This commit is contained in:
Sebastian Markbåge 2025-04-22 19:29:12 -04:00 committed by GitHub
parent 620c838fb6
commit ebf7318e87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 183 additions and 0 deletions

View File

@ -1127,6 +1127,61 @@ export function clearSuspenseBoundaryFromContainer(
retryIfBlockedOn(container);
}
function hideOrUnhideSuspenseBoundary(
suspenseInstance: SuspenseInstance,
isHidden: boolean,
) {
let node: Node = suspenseInstance;
// Unhide all nodes within this suspense boundary.
let depth = 0;
do {
const nextNode = node.nextSibling;
if (node.nodeType === ELEMENT_NODE) {
const instance = ((node: any): HTMLElement & {_stashedDisplay?: string});
if (isHidden) {
instance._stashedDisplay = instance.style.display;
instance.style.display = 'none';
} else {
instance.style.display = instance._stashedDisplay || '';
if (instance.getAttribute('style') === '') {
instance.removeAttribute('style');
}
}
} else if (node.nodeType === TEXT_NODE) {
const textNode = ((node: any): Text & {_stashedText?: string});
if (isHidden) {
textNode._stashedText = textNode.nodeValue;
textNode.nodeValue = '';
} else {
textNode.nodeValue = textNode._stashedText || '';
}
}
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
const data = ((nextNode: any).data: string);
if (data === SUSPENSE_END_DATA) {
if (depth === 0) {
return;
} else {
depth--;
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA
) {
depth++;
}
// TODO: Should we hide preamble contribution in this case?
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
node = nextNode;
} while (node);
}
export function hideSuspenseBoundary(suspenseInstance: SuspenseInstance): void {
hideOrUnhideSuspenseBoundary(suspenseInstance, true);
}
export function hideInstance(instance: Instance): void {
// TODO: Does this work for all element types? What about MathML? Should we
// pass host context to this method?
@ -1144,6 +1199,12 @@ export function hideTextInstance(textInstance: TextInstance): void {
textInstance.nodeValue = '';
}
export function unhideSuspenseBoundary(
suspenseInstance: SuspenseInstance,
): void {
hideOrUnhideSuspenseBoundary(suspenseInstance, false);
}
export function unhideInstance(instance: Instance, props: Props): void {
instance = ((instance: any): HTMLElement);
const styleProp = props[STYLE];

View File

@ -3986,4 +3986,94 @@ describe('ReactDOMServerPartialHydration', () => {
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
]);
});
it('hides a dehydrated suspense boundary if the parent resuspends', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function Sibling({resuspend}) {
if (suspend && resuspend) {
throw promise;
} else {
return null;
}
}
function Component({text}) {
return (
<Suspense>
<Child text={text} />
<span ref={ref}>World</span>
</Suspense>
);
}
function App({text, resuspend}) {
const memoized = React.useMemo(() => <Component text={text} />, [text]);
return (
<div>
<Suspense fallback="Loading...">
{memoized}
<Sibling resuspend={resuspend} />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App text="Hello" />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
// On the client we don't have all data yet but we want to start
// hydrating anyway.
suspend = true;
const root = ReactDOMClient.hydrateRoot(container, <App text="Hello" />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([]);
expect(ref.current).toBe(null); // Still dehydrated
const span = container.getElementsByTagName('span')[0];
const textNode = span.previousSibling;
expect(textNode.nodeValue).toBe('Hello');
expect(span.textContent).toBe('World');
// Render an update, that resuspends the parent boundary.
// Flushing now now hide the text content.
await act(() => {
root.render(<App text="Hello" resuspend={true} />);
});
expect(ref.current).toBe(null);
expect(span.style.display).toBe('none');
expect(textNode.nodeValue).toBe('');
// Unsuspending shows the content.
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(textNode.nodeValue).toBe('Hello');
expect(span.textContent).toBe('World');
expect(span.style.display).toBe('');
expect(ref.current).toBe(span);
});
});

View File

@ -41,8 +41,10 @@ import {
insertBefore,
insertInContainerBefore,
replaceContainerChildren,
hideSuspenseBoundary,
hideInstance,
hideTextInstance,
unhideSuspenseBoundary,
unhideInstance,
unhideTextInstance,
commitHydratedContainer,
@ -152,6 +154,27 @@ export function commitHostResetTextContent(finishedWork: Fiber) {
}
}
export function commitShowHideSuspenseBoundary(node: Fiber, isHidden: boolean) {
try {
const instance = node.stateNode;
if (isHidden) {
if (__DEV__) {
runWithFiberInDEV(node, hideSuspenseBoundary, instance);
} else {
hideSuspenseBoundary(instance);
}
} else {
if (__DEV__) {
runWithFiberInDEV(node, unhideSuspenseBoundary, node.stateNode);
} else {
unhideSuspenseBoundary(node.stateNode);
}
}
} catch (error) {
captureCommitPhaseError(node, node.return, error);
}
}
export function commitShowHideHostInstance(node: Fiber, isHidden: boolean) {
try {
const instance = node.stateNode;

View File

@ -227,6 +227,7 @@ import {
commitHostUpdate,
commitHostTextUpdate,
commitHostResetTextContent,
commitShowHideSuspenseBoundary,
commitShowHideHostInstance,
commitShowHideHostTextInstance,
commitHostPlacement,
@ -1158,6 +1159,10 @@ function hideOrUnhideAllChildren(finishedWork: Fiber, isHidden: boolean) {
if (hostSubtreeRoot === null) {
commitShowHideHostTextInstance(node, isHidden);
}
} else if (node.tag === DehydratedFragment) {
if (hostSubtreeRoot === null) {
commitShowHideSuspenseBoundary(node, isHidden);
}
} else if (
(node.tag === OffscreenComponent ||
node.tag === LegacyHiddenComponent) &&

View File

@ -44,6 +44,8 @@ export const commitHydratedContainer = shim;
export const commitHydratedSuspenseInstance = shim;
export const clearSuspenseBoundary = shim;
export const clearSuspenseBoundaryFromContainer = shim;
export const hideSuspenseBoundary = shim;
export const unhideSuspenseBoundary = shim;
export const shouldDeleteUnhydratedTailInstances = shim;
export const diffHydratedPropsForDevWarnings = shim;
export const diffHydratedTextForDevWarnings = shim;

View File

@ -220,6 +220,8 @@ export const commitHydratedSuspenseInstance =
export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
export const clearSuspenseBoundaryFromContainer =
$$$config.clearSuspenseBoundaryFromContainer;
export const hideSuspenseBoundary = $$$config.hideSuspenseBoundary;
export const unhideSuspenseBoundary = $$$config.unhideSuspenseBoundary;
export const shouldDeleteUnhydratedTailInstances =
$$$config.shouldDeleteUnhydratedTailInstances;
export const diffHydratedPropsForDevWarnings =