Skip to content

Commit 2e6d853

Browse files
fix(router): use route loading components during hydration (#8859)
1 parent ca7563f commit 2e6d853

9 files changed

Lines changed: 112 additions & 0 deletions

File tree

‎.changeset/tiny-routers-hydrate.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modern-js/runtime-utils': patch
3+
---
4+
5+
fix: use conventional route loading components as hydration fallbacks while initial route data is loading
6+
7+
fix: 初始路由数据加载期间使用约定式路由的 loading 组件作为 hydration fallback

‎packages/toolkit/runtime-utils/src/browser/nestedRoutes.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ export const renderNestedRoute = (
113113
routeProps.element = element;
114114
}
115115

116+
if (nestedRoute.loading) {
117+
routeProps.hydrateFallbackElement = <nestedRoute.loading />;
118+
}
119+
116120
const childElements = children?.map(childRoute => {
117121
return renderNestedRoute(childRoute, {
118122
parent: nestedRoute,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const loader = () => ({ title: 'Hydrated layout' });
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Outlet, useLoaderData } from '@modern-js/runtime/router';
2+
import type { loader } from './layout.data';
3+
4+
export default function Layout() {
5+
const data = useLoaderData<typeof loader>();
6+
return (
7+
<section id="hydration-layout">
8+
{data.title}
9+
<Outlet />
10+
</section>
11+
);
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Loading() {
2+
return <div id="nested-hydration-loading">Loading nested data</div>;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <div id="hydration-page">Hydrated page</div>;
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const loader = () => ({ title: 'Root layout' });
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Loading() {
2+
return <div id="root-hydration-loading">Loading root data</div>;
3+
}

‎tests/integration/ssr/tests/streaming.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,82 @@ describe('Streaming SSR', () => {
177177
test('should render fallback before final content', async () => {
178178
await streamingOrderOnServer(appPort);
179179
});
180+
181+
test.each([
182+
['layout', '#root-hydration-loading'],
183+
['hydration/layout', '#nested-hydration-loading'],
184+
])('renders loading while hydrating %s', async (routeId, selector) => {
185+
const hydrationPage = await browser.newPage();
186+
const warnings: string[] = [];
187+
hydrationPage.on('console', message => {
188+
if (message.text().includes('No `HydrateFallback`')) {
189+
warnings.push(message.text());
190+
}
191+
});
192+
193+
try {
194+
// Simulate incomplete SSR data before the client creates its router.
195+
await hydrationPage.evaluateOnNewDocument(id => {
196+
let routerData: { loaderData: Record<string, unknown> };
197+
Object.defineProperty(window, '_ROUTER_DATA', {
198+
configurable: true,
199+
get: () => routerData,
200+
set: value => {
201+
routerData = value;
202+
delete routerData.loaderData[id];
203+
},
204+
});
205+
}, routeId);
206+
207+
// Hold the client loader request until the fallback has been observed.
208+
await hydrationPage.setRequestInterception(true);
209+
hydrationPage.on('request', request => {
210+
if (new URL(request.url()).searchParams.get('__loader') !== routeId) {
211+
void request.continue();
212+
}
213+
});
214+
const loaderRequest = hydrationPage.waitForRequest(
215+
request =>
216+
new URL(request.url()).searchParams.get('__loader') === routeId,
217+
);
218+
await hydrationPage.goto(`http://localhost:${appPort}/hydration`, {
219+
waitUntil: 'domcontentloaded',
220+
});
221+
const request = await loaderRequest;
222+
await hydrationPage.waitForSelector(selector, { visible: true });
223+
expect(await hydrationPage.$('#hydration-page')).toBeNull();
224+
if (routeId === 'hydration/layout') {
225+
await expectPageToMatchTextContent(hydrationPage, 'Root layout');
226+
}
227+
228+
await request.continue();
229+
await hydrationPage.waitForSelector('#hydration-page', { visible: true });
230+
await expectPageToMatchTextContent(hydrationPage, 'Hydrated layout');
231+
expect(await hydrationPage.$(selector)).toBeNull();
232+
expect(warnings).toEqual([]);
233+
} finally {
234+
await hydrationPage.close();
235+
}
236+
});
237+
238+
test('reuses complete hydration data without rerunning loaders', async () => {
239+
const hydrationPage = await browser.newPage();
240+
const loaderRequests: string[] = [];
241+
hydrationPage.on('request', request => {
242+
if (new URL(request.url()).searchParams.has('__loader')) {
243+
loaderRequests.push(request.url());
244+
}
245+
});
246+
try {
247+
await hydrationPage.goto(`http://localhost:${appPort}/hydration`, {
248+
waitUntil: 'networkidle0',
249+
});
250+
await hydrationPage.waitForSelector('#hydration-page', { visible: true });
251+
expect(await hydrationPage.$('#root-hydration-loading')).toBeNull();
252+
expect(await hydrationPage.$('#nested-hydration-loading')).toBeNull();
253+
expect(loaderRequests).toEqual([]);
254+
} finally {
255+
await hydrationPage.close();
256+
}
257+
});
180258
});

0 commit comments

Comments
 (0)