Skip to content

Commit a534bdf

Browse files
committed
Dehydrated suspense boundaries in suspense list
If we get an insertion after a boundary, that has not yet been hydrated, we take our best guess at which state the HTML is showing. isSuspenseInstancePending means that we're still waiting for more server HTML before we can hydrate. This should mean that we're showing the fallback state. isSuspenseInstanceFallback means that we want to client render something. That most likely means that the server was unable to render and is displaying a fallback state in this slot. Adds tests to ensure that dehydrated components don't consider the force flag.
1 parent 6d2a9ab commit a534bdf

File tree

2 files changed

+264
-1
lines changed

2 files changed

+264
-1
lines changed

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

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ let ReactDOMServer;
1515
let Scheduler;
1616
let ReactFeatureFlags;
1717
let Suspense;
18+
let SuspenseList;
1819
let act;
1920

2021
describe('ReactDOMServerPartialHydration', () => {
@@ -30,6 +31,7 @@ describe('ReactDOMServerPartialHydration', () => {
3031
ReactDOMServer = require('react-dom/server');
3132
Scheduler = require('scheduler');
3233
Suspense = React.Suspense;
34+
SuspenseList = React.unstable_SuspenseList;
3335
});
3436

3537
it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
@@ -1077,6 +1079,256 @@ describe('ReactDOMServerPartialHydration', () => {
10771079
expect(ref.current).toBe(div);
10781080
});
10791081

1082+
it('shows inserted items in a SuspenseList before content is hydrated', async () => {
1083+
let suspend = false;
1084+
let resolve;
1085+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
1086+
let ref = React.createRef();
1087+
1088+
function Child({children}) {
1089+
if (suspend) {
1090+
throw promise;
1091+
} else {
1092+
return children;
1093+
}
1094+
}
1095+
1096+
// These are hoisted to avoid them from rerendering.
1097+
const a = (
1098+
<Suspense fallback="Loading A">
1099+
<Child>
1100+
<span>A</span>
1101+
</Child>
1102+
</Suspense>
1103+
);
1104+
const b = (
1105+
<Suspense fallback="Loading B">
1106+
<Child>
1107+
<span ref={ref}>B</span>
1108+
</Child>
1109+
</Suspense>
1110+
);
1111+
1112+
function App({showMore}) {
1113+
return (
1114+
<SuspenseList revealOrder="forwards">
1115+
{a}
1116+
{b}
1117+
{showMore ? (
1118+
<Suspense fallback="Loading C">
1119+
<span>C</span>
1120+
</Suspense>
1121+
) : null}
1122+
</SuspenseList>
1123+
);
1124+
}
1125+
1126+
suspend = false;
1127+
let html = ReactDOMServer.renderToString(<App showMore={false} />);
1128+
1129+
let container = document.createElement('div');
1130+
container.innerHTML = html;
1131+
1132+
let spanB = container.getElementsByTagName('span')[1];
1133+
1134+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1135+
1136+
suspend = true;
1137+
act(() => {
1138+
root.render(<App showMore={false} />);
1139+
});
1140+
1141+
// We're not hydrated yet.
1142+
expect(ref.current).toBe(null);
1143+
expect(container.textContent).toBe('AB');
1144+
1145+
// Add more rows before we've hydrated the first two.
1146+
act(() => {
1147+
root.render(<App showMore={true} />);
1148+
});
1149+
1150+
// We're not hydrated yet.
1151+
expect(ref.current).toBe(null);
1152+
1153+
// Since the first two are already showing their final content
1154+
// we should be able to show the real content.
1155+
expect(container.textContent).toBe('ABC');
1156+
1157+
suspend = false;
1158+
await act(async () => {
1159+
await resolve();
1160+
});
1161+
1162+
expect(container.textContent).toBe('ABC');
1163+
// We've hydrated the same span.
1164+
expect(ref.current).toBe(spanB);
1165+
});
1166+
1167+
it('shows is able to hydrate boundaries even if others in a list are pending', async () => {
1168+
let suspend = false;
1169+
let resolve;
1170+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
1171+
let ref = React.createRef();
1172+
1173+
function Child({children}) {
1174+
if (suspend) {
1175+
throw promise;
1176+
} else {
1177+
return children;
1178+
}
1179+
}
1180+
1181+
let promise2 = new Promise(() => {});
1182+
function AlwaysSuspend() {
1183+
throw promise2;
1184+
}
1185+
1186+
// This is hoisted to avoid them from rerendering.
1187+
const a = (
1188+
<Suspense fallback="Loading A">
1189+
<Child>
1190+
<span ref={ref}>A</span>
1191+
</Child>
1192+
</Suspense>
1193+
);
1194+
1195+
function App({showMore}) {
1196+
return (
1197+
<SuspenseList revealOrder="together">
1198+
{a}
1199+
{showMore ? (
1200+
<Suspense fallback="Loading B">
1201+
<AlwaysSuspend />
1202+
</Suspense>
1203+
) : null}
1204+
</SuspenseList>
1205+
);
1206+
}
1207+
1208+
suspend = false;
1209+
let html = ReactDOMServer.renderToString(<App showMore={false} />);
1210+
1211+
let container = document.createElement('div');
1212+
container.innerHTML = html;
1213+
1214+
let spanA = container.getElementsByTagName('span')[0];
1215+
1216+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1217+
1218+
suspend = true;
1219+
act(() => {
1220+
root.render(<App showMore={false} />);
1221+
});
1222+
1223+
// We're not hydrated yet.
1224+
expect(ref.current).toBe(null);
1225+
expect(container.textContent).toBe('A');
1226+
1227+
await act(async () => {
1228+
// Add another row before we've hydrated the first one.
1229+
root.render(<App showMore={true} />);
1230+
// At the same time, we resolve the blocking promise.
1231+
suspend = false;
1232+
await resolve();
1233+
});
1234+
1235+
// We should have been able to hydrate the first row.
1236+
expect(ref.current).toBe(spanA);
1237+
// Even though we're still slowing B.
1238+
expect(container.textContent).toBe('ALoading B');
1239+
});
1240+
1241+
it('shows inserted items before pending in a SuspenseList as fallbacks', async () => {
1242+
let suspend = false;
1243+
let resolve;
1244+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
1245+
let ref = React.createRef();
1246+
1247+
function Child({children}) {
1248+
if (suspend) {
1249+
throw promise;
1250+
} else {
1251+
return children;
1252+
}
1253+
}
1254+
1255+
// These are hoisted to avoid them from rerendering.
1256+
const a = (
1257+
<Suspense fallback="Loading A">
1258+
<Child>
1259+
<span>A</span>
1260+
</Child>
1261+
</Suspense>
1262+
);
1263+
const b = (
1264+
<Suspense fallback="Loading B">
1265+
<Child>
1266+
<span ref={ref}>B</span>
1267+
</Child>
1268+
</Suspense>
1269+
);
1270+
1271+
function App({showMore}) {
1272+
return (
1273+
<SuspenseList revealOrder="forwards">
1274+
{a}
1275+
{b}
1276+
{showMore ? (
1277+
<Suspense fallback="Loading C">
1278+
<span>C</span>
1279+
</Suspense>
1280+
) : null}
1281+
</SuspenseList>
1282+
);
1283+
}
1284+
1285+
suspend = false;
1286+
let html = ReactDOMServer.renderToString(<App showMore={false} />);
1287+
1288+
let container = document.createElement('div');
1289+
container.innerHTML = html;
1290+
1291+
let suspenseNode = container.firstChild;
1292+
expect(suspenseNode.nodeType).toBe(8);
1293+
// Put the suspense node in pending state.
1294+
suspenseNode.data = '$?';
1295+
1296+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1297+
1298+
suspend = true;
1299+
act(() => {
1300+
root.render(<App showMore={false} />);
1301+
});
1302+
1303+
// We're not hydrated yet.
1304+
expect(ref.current).toBe(null);
1305+
expect(container.textContent).toBe('AB');
1306+
1307+
// Add more rows before we've hydrated the first two.
1308+
act(() => {
1309+
root.render(<App showMore={true} />);
1310+
});
1311+
1312+
// We're not hydrated yet.
1313+
expect(ref.current).toBe(null);
1314+
1315+
// Since the first two are already showing their final content
1316+
// we should be able to show the real content.
1317+
expect(container.textContent).toBe('ABLoading C');
1318+
1319+
suspend = false;
1320+
await act(async () => {
1321+
// Resolve the boundary to be in its resolved final state.
1322+
suspenseNode.data = '$';
1323+
if (suspenseNode._reactRetry) {
1324+
suspenseNode._reactRetry();
1325+
}
1326+
await resolve();
1327+
});
1328+
1329+
expect(container.textContent).toBe('ABC');
1330+
});
1331+
10801332
it('can client render nested boundaries', async () => {
10811333
let suspend = false;
10821334
let promise = new Promise(() => {});

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {Fiber} from './ReactFiber';
1111
import type {SuspenseInstance} from './ReactFiberHostConfig';
1212
import {SuspenseComponent, SuspenseListComponent} from 'shared/ReactWorkTags';
1313
import {NoEffect, DidCapture} from 'shared/ReactSideEffectTags';
14+
import {
15+
isSuspenseInstancePending,
16+
isSuspenseInstanceFallback,
17+
} from './ReactFiberHostConfig';
1418

1519
export type SuspenseState = {|
1620
// If this boundary is still dehydrated, we store the SuspenseInstance
@@ -76,7 +80,14 @@ export function findFirstSuspended(row: Fiber): null | Fiber {
7680
if (node.tag === SuspenseComponent) {
7781
const state: SuspenseState | null = node.memoizedState;
7882
if (state !== null) {
79-
return node;
83+
const dehydrated: null | SuspenseInstance = state.dehydrated;
84+
if (
85+
dehydrated === null ||
86+
isSuspenseInstancePending(dehydrated) ||
87+
isSuspenseInstanceFallback(dehydrated)
88+
) {
89+
return node;
90+
}
8091
}
8192
} else if (
8293
node.tag === SuspenseListComponent &&

0 commit comments

Comments
 (0)