Skip to content

Commit 006e201

Browse files
committed
Hide/unhide the content of dehydrated suspense boundaries if they resuspend
1 parent 961b625 commit 006e201

File tree

6 files changed

+181
-0
lines changed

6 files changed

+181
-0
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,61 @@ export function clearSuspenseBoundaryFromContainer(
11271127
retryIfBlockedOn(container);
11281128
}
11291129

1130+
function hideOrUnhideSuspenseBoundary(
1131+
suspenseInstance: SuspenseInstance,
1132+
isHidden: boolean,
1133+
) {
1134+
let node: Node = suspenseInstance;
1135+
// Unhide all nodes within this suspense boundary.
1136+
let depth = 0;
1137+
do {
1138+
const nextNode = node.nextSibling;
1139+
if (node.nodeType === ELEMENT_NODE) {
1140+
const instance = ((node: any): HTMLElement & {_stashedDisplay?: string});
1141+
if (isHidden) {
1142+
instance._stashedDisplay = instance.style.display;
1143+
instance.style.display = 'none';
1144+
} else {
1145+
instance.style.display = instance._stashedDisplay || '';
1146+
if (instance.getAttribute('style') === '') {
1147+
instance.removeAttribute('style');
1148+
}
1149+
}
1150+
} else if (node.nodeType === TEXT_NODE) {
1151+
const textNode = ((node: any): Text & {_stashedText?: string});
1152+
if (isHidden) {
1153+
textNode._stashedText = textNode.nodeValue;
1154+
textNode.nodeValue = '';
1155+
} else {
1156+
textNode.nodeValue = textNode._stashedText || '';
1157+
}
1158+
}
1159+
if (nextNode && nextNode.nodeType === COMMENT_NODE) {
1160+
const data = ((nextNode: any).data: string);
1161+
if (data === SUSPENSE_END_DATA) {
1162+
if (depth === 0) {
1163+
return;
1164+
} else {
1165+
depth--;
1166+
}
1167+
} else if (
1168+
data === SUSPENSE_START_DATA ||
1169+
data === SUSPENSE_PENDING_START_DATA ||
1170+
data === SUSPENSE_FALLBACK_START_DATA
1171+
) {
1172+
depth++;
1173+
}
1174+
// TODO: Should we hide preamble contribution in this case?
1175+
}
1176+
// $FlowFixMe[incompatible-type] we bail out when we get a null
1177+
node = nextNode;
1178+
} while (node);
1179+
}
1180+
1181+
export function hideSuspenseBoundary(suspenseInstance: SuspenseInstance): void {
1182+
hideOrUnhideSuspenseBoundary(suspenseInstance, true);
1183+
}
1184+
11301185
export function hideInstance(instance: Instance): void {
11311186
// TODO: Does this work for all element types? What about MathML? Should we
11321187
// pass host context to this method?
@@ -1144,6 +1199,12 @@ export function hideTextInstance(textInstance: TextInstance): void {
11441199
textInstance.nodeValue = '';
11451200
}
11461201

1202+
export function unhideSuspenseBoundary(
1203+
suspenseInstance: SuspenseInstance,
1204+
): void {
1205+
hideOrUnhideSuspenseBoundary(suspenseInstance, false);
1206+
}
1207+
11471208
export function unhideInstance(instance: Instance, props: Props): void {
11481209
instance = ((instance: any): HTMLElement);
11491210
const styleProp = props[STYLE];

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3945,4 +3945,94 @@ describe('ReactDOMServerPartialHydration', () => {
39453945
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
39463946
]);
39473947
});
3948+
3949+
it('hides a dehydrated suspense boundary if the parent resuspends', async () => {
3950+
let suspend = false;
3951+
let resolve;
3952+
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
3953+
const ref = React.createRef();
3954+
3955+
function Child({text}) {
3956+
if (suspend) {
3957+
throw promise;
3958+
} else {
3959+
return text;
3960+
}
3961+
}
3962+
3963+
function Sibling({resuspend}) {
3964+
if (suspend && resuspend) {
3965+
throw promise;
3966+
} else {
3967+
return null;
3968+
}
3969+
}
3970+
3971+
function Component({text}) {
3972+
return (
3973+
<Suspense>
3974+
<Child text={text} />
3975+
<span ref={ref}>World</span>
3976+
</Suspense>
3977+
);
3978+
}
3979+
3980+
function App({text, resuspend}) {
3981+
const memoized = React.useMemo(() => <Component text={text} />, [text]);
3982+
return (
3983+
<div>
3984+
<Suspense fallback="Loading...">
3985+
{memoized}
3986+
<Sibling resuspend={resuspend} />
3987+
</Suspense>
3988+
</div>
3989+
);
3990+
}
3991+
3992+
suspend = false;
3993+
const finalHTML = ReactDOMServer.renderToString(<App text="Hello" />);
3994+
const container = document.createElement('div');
3995+
container.innerHTML = finalHTML;
3996+
3997+
// On the client we don't have all data yet but we want to start
3998+
// hydrating anyway.
3999+
suspend = true;
4000+
const root = ReactDOMClient.hydrateRoot(container, <App text="Hello" />, {
4001+
onRecoverableError(error) {
4002+
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
4003+
if (error.cause) {
4004+
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
4005+
}
4006+
},
4007+
});
4008+
await waitForAll([]);
4009+
4010+
expect(ref.current).toBe(null); // Still dehydrated
4011+
const span = container.getElementsByTagName('span')[0];
4012+
const textNode = span.previousSibling;
4013+
expect(textNode.nodeValue).toBe('Hello');
4014+
expect(span.textContent).toBe('World');
4015+
4016+
// Render an update, that resuspends the parent boundary.
4017+
// Flushing now now hide the text content.
4018+
await act(() => {
4019+
root.render(<App text="Hello" resuspend={true} />);
4020+
});
4021+
4022+
expect(ref.current).toBe(null);
4023+
expect(span.style.display).toBe('none');
4024+
expect(textNode.nodeValue).toBe('');
4025+
4026+
// Unsuspending shows the content.
4027+
await act(async () => {
4028+
suspend = false;
4029+
resolve();
4030+
await promise;
4031+
});
4032+
4033+
expect(textNode.nodeValue).toBe('Hello');
4034+
expect(span.textContent).toBe('World');
4035+
expect(span.style.display).toBe('');
4036+
expect(ref.current).toBe(span);
4037+
});
39484038
});

packages/react-reconciler/src/ReactFiberCommitHostEffects.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ import {
4141
insertBefore,
4242
insertInContainerBefore,
4343
replaceContainerChildren,
44+
hideSuspenseBoundary,
4445
hideInstance,
4546
hideTextInstance,
47+
unhideSuspenseBoundary,
4648
unhideInstance,
4749
unhideTextInstance,
4850
commitHydratedContainer,
@@ -152,6 +154,27 @@ export function commitHostResetTextContent(finishedWork: Fiber) {
152154
}
153155
}
154156

157+
export function commitShowHideSuspenseBoundary(node: Fiber, isHidden: boolean) {
158+
try {
159+
const instance = node.stateNode;
160+
if (isHidden) {
161+
if (__DEV__) {
162+
runWithFiberInDEV(node, hideSuspenseBoundary, instance);
163+
} else {
164+
hideSuspenseBoundary(instance);
165+
}
166+
} else {
167+
if (__DEV__) {
168+
runWithFiberInDEV(node, unhideSuspenseBoundary, node.stateNode);
169+
} else {
170+
unhideSuspenseBoundary(node.stateNode);
171+
}
172+
}
173+
} catch (error) {
174+
captureCommitPhaseError(node, node.return, error);
175+
}
176+
}
177+
155178
export function commitShowHideHostInstance(node: Fiber, isHidden: boolean) {
156179
try {
157180
const instance = node.stateNode;

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ import {
227227
commitHostUpdate,
228228
commitHostTextUpdate,
229229
commitHostResetTextContent,
230+
commitShowHideSuspenseBoundary,
230231
commitShowHideHostInstance,
231232
commitShowHideHostTextInstance,
232233
commitHostPlacement,
@@ -1158,6 +1159,10 @@ function hideOrUnhideAllChildren(finishedWork: Fiber, isHidden: boolean) {
11581159
if (hostSubtreeRoot === null) {
11591160
commitShowHideHostTextInstance(node, isHidden);
11601161
}
1162+
} else if (node.tag === DehydratedFragment) {
1163+
if (hostSubtreeRoot === null) {
1164+
commitShowHideSuspenseBoundary(node, isHidden);
1165+
}
11611166
} else if (
11621167
(node.tag === OffscreenComponent ||
11631168
node.tag === LegacyHiddenComponent) &&

packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const commitHydratedContainer = shim;
4444
export const commitHydratedSuspenseInstance = shim;
4545
export const clearSuspenseBoundary = shim;
4646
export const clearSuspenseBoundaryFromContainer = shim;
47+
export const hideSuspenseBoundary = shim;
4748
export const shouldDeleteUnhydratedTailInstances = shim;
4849
export const diffHydratedPropsForDevWarnings = shim;
4950
export const diffHydratedTextForDevWarnings = shim;

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ export const commitHydratedSuspenseInstance =
220220
export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary;
221221
export const clearSuspenseBoundaryFromContainer =
222222
$$$config.clearSuspenseBoundaryFromContainer;
223+
export const hideSuspenseBoundary = $$$config.hideSuspenseBoundary;
223224
export const shouldDeleteUnhydratedTailInstances =
224225
$$$config.shouldDeleteUnhydratedTailInstances;
225226
export const diffHydratedPropsForDevWarnings =

0 commit comments

Comments
 (0)