Skip to content

Commit 8494edd

Browse files
fix(loadable-react-18): harden ssr startup flow (#4384)
1 parent e740e6a commit 8494edd

File tree

9 files changed

+206
-26
lines changed

9 files changed

+206
-26
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './compiled-types/src/client/components/Content';
2+
export { default } from './compiled-types/src/client/components/Content';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type RemoteKeys = 'app2/Content';
2+
export type PackageType<T> = T extends 'app2/Content' ? typeof import('app2/Content') : any;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
export interface ContentProps {
3+
content?: string;
4+
}
5+
declare const Content: React.FC<ContentProps>;
6+
export default Content;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { PackageType as App2PackageType, RemoteKeys as App2RemoteKeys } from './app2/apis.d.ts';
2+
3+
declare module '@module-federation/runtime' {
4+
type RemoteKeys = App2RemoteKeys;
5+
type PackageType<T, Fallback = any> = T extends RemoteKeys ? App2PackageType<T> : Fallback;
6+
export function loadRemote<T extends RemoteKeys, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
7+
export function loadRemote<T extends string, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
8+
}
9+
10+
declare module '@module-federation/enhanced/runtime' {
11+
type RemoteKeys = App2RemoteKeys;
12+
type PackageType<T, Fallback = any> = T extends RemoteKeys ? App2PackageType<T> : Fallback;
13+
export function loadRemote<T extends RemoteKeys, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
14+
export function loadRemote<T extends string, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
15+
}
16+
17+
declare module '@module-federation/runtime-tools' {
18+
type RemoteKeys = App2RemoteKeys;
19+
type PackageType<T, Fallback = any> = T extends RemoteKeys ? App2PackageType<T> : Fallback;
20+
export function loadRemote<T extends RemoteKeys, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
21+
export function loadRemote<T extends string, Fallback>(packageName: T): Promise<PackageType<T, Fallback>>;
22+
}

loadable-react-18/app1/src/server/mfFunctions.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,31 @@ const isMfComponent = component => mfAppNamesRegex.test(component);
1313
* @return {string[]} chunk ids of the rendered components.
1414
*/
1515
export const getLoadableRequiredComponents = extractor => {
16-
const loadableElement = extractor
17-
.getScriptElements()
18-
.find(el => el.key === '__LOADABLE_REQUIRED_CHUNKS___ext');
16+
const scriptElements = extractor?.getScriptElements?.() ?? [];
1917

20-
const { namedChunks } = JSON.parse(loadableElement.props.dangerouslySetInnerHTML.__html);
18+
const loadableElement = scriptElements.find(
19+
el => el?.key === '__LOADABLE_REQUIRED_CHUNKS___ext',
20+
);
2121

22-
return namedChunks;
22+
if (!loadableElement) {
23+
return [];
24+
}
25+
26+
try {
27+
const rawHtml = loadableElement.props?.dangerouslySetInnerHTML?.__html;
28+
29+
if (!rawHtml) {
30+
return [];
31+
}
32+
33+
const parsedData = JSON.parse(rawHtml);
34+
const { namedChunks } = parsedData ?? {};
35+
36+
return Array.isArray(namedChunks) ? namedChunks : [];
37+
} catch (error) {
38+
console.error('[getLoadableRequiredComponents] Failed to parse required chunks', error);
39+
return [];
40+
}
2341
};
2442

2543
const getMfRenderedComponents = loadableRequiredComponents => {
@@ -31,21 +49,48 @@ const getMfRenderedComponents = loadableRequiredComponents => {
3149

3250
const getMFStats = async () => {
3351
const promises = Object.values(mfStatsUrlMap).map(url => axios.get(url));
34-
return Promise.all(promises).then(responses => responses.map(response => response.data));
52+
53+
try {
54+
const responses = await Promise.all(promises);
55+
56+
return responses.map(response => response.data);
57+
} catch (error) {
58+
console.error('[getMFStats] Failed to fetch remote federation stats', error);
59+
return [];
60+
}
3561
};
3662

3763
export const getMfChunks = async extractor => {
3864
const loadableRequiredComponents = getLoadableRequiredComponents(extractor);
3965

66+
if (!loadableRequiredComponents.length) {
67+
return [[], []];
68+
}
69+
4070
const mfRenderedComponents = getMfRenderedComponents(loadableRequiredComponents);
4171

72+
if (!mfRenderedComponents.length) {
73+
return [[], []];
74+
}
75+
4276
const mfChunks = await getMFStats();
4377

44-
const scriptsArr = [];
45-
const stylesArr = [];
78+
if (!mfChunks.length) {
79+
return [[], []];
80+
}
81+
82+
const scriptsArr: string[] = [];
83+
const stylesArr: string[] = [];
84+
4685
mfRenderedComponents.forEach(([appName, component]) => {
47-
const remoteStats = mfChunks.find(remote => remote.name === appName);
48-
remoteStats.exposes[component].forEach(chunk => {
86+
const remoteStats = mfChunks.find(remote => remote?.name === appName);
87+
const exposeChunks = remoteStats?.exposes?.[component];
88+
89+
if (!Array.isArray(exposeChunks)) {
90+
return;
91+
}
92+
93+
exposeChunks.forEach(chunk => {
4994
const url = 'http://localhost:3001/static/' + chunk;
5095

5196
url.endsWith('.css') ? stylesArr.push(url) : scriptsArr.push(url);

loadable-react-18/app1/src/server/renderAndExtractContext.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,46 @@ export async function renderAndExtractContext({
2727
// @loadable chunk extractor
2828
chunkExtractor,
2929
}: RenderAndExtractContextOptions) {
30-
const { default: App } = await import('../client/components/App');
30+
let markup = '';
3131

32-
// This not work, The ChunkExtractorManager context provider
33-
// do not pass the chunkExtractor to the context consumer (ChunkExtractorManager)
34-
// const markup = await renderToString(chunkExtractor.collectChunks(<App />));
32+
try {
33+
const { default: App } = await import('../client/components/App');
3534

36-
const markup = await renderToStaticMarkup(
37-
<ChunkExtractorManager {...{ extractor: chunkExtractor }}>
38-
<App />
39-
</ChunkExtractorManager>,
40-
);
35+
// This not work, The ChunkExtractorManager context provider
36+
// do not pass the chunkExtractor to the context consumer (ChunkExtractorManager)
37+
// const markup = await renderToString(chunkExtractor.collectChunks(<App />));
4138

42-
const linkTags = chunkExtractor.getLinkTags();
43-
const scriptTags = chunkExtractor.getScriptTags();
39+
markup = await renderToStaticMarkup(
40+
<ChunkExtractorManager {...{ extractor: chunkExtractor }}>
41+
<App />
42+
</ChunkExtractorManager>,
43+
);
44+
} catch (error) {
45+
console.error('[renderAndExtractContext] Failed to render App component', error);
46+
}
47+
48+
let linkTags = '';
49+
let scriptTags = '';
50+
51+
try {
52+
linkTags = chunkExtractor.getLinkTags();
53+
scriptTags = chunkExtractor.getScriptTags();
54+
} catch (error) {
55+
console.error('[renderAndExtractContext] Failed to collect chunk tags', error);
56+
}
4457

4558
// ================ WORKAROUND ================
46-
const [mfRequiredScripts, mfRequiredStyles] = await getMfChunks(chunkExtractor);
59+
let mfScriptTags = '';
60+
let mfStyleTags = '';
61+
62+
try {
63+
const [mfRequiredScripts, mfRequiredStyles] = await getMfChunks(chunkExtractor);
4764

48-
const mfScriptTags = mfRequiredScripts.map(createScriptTag).join('');
49-
const mfStyleTags = mfRequiredStyles.map(createStyleTag).join('');
65+
mfScriptTags = mfRequiredScripts.map(createScriptTag).join('');
66+
mfStyleTags = mfRequiredStyles.map(createStyleTag).join('');
67+
} catch (error) {
68+
console.error('[renderAndExtractContext] Failed to collect module federation chunks', error);
69+
}
5070
// ================ WORKAROUND ================
5171

5272
console.log('mfScriptTags', mfScriptTags);

loadable-react-18/app1/src/server/serverRender.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export default async function serverRender(req, res, next) {
4343
console.error('[renderAndExtractContext serverRender]', error);
4444
}
4545

46-
const { markup, linkTags, scriptTags } = result as RenderAndExtractContextResult;
46+
const { markup = '', linkTags = '', scriptTags = '' } = (result || {}) as Partial<RenderAndExtractContextResult>;
4747

4848
res.write(`<head>${linkTags}</head><body>`);
4949
res.write(`<div id="root">${markup}</div>`);

loadable-react-18/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"ignored": true,
44
"version": "0.0.1",
55
"scripts": {
6-
"start": "pnpm --filter loadable-react-18_* --parallel start",
6+
"start": "node scripts/start.js",
77
"build": "pnpm --filter loadable-react-18_* build",
88
"serve": "pnpm --filter loadable-react-18_* --parallel serve",
99
"clean": "pnpm --filter loadable-react-18_* --parallel clean",

loadable-react-18/scripts/start.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const { spawn } = require('node:child_process');
2+
const process = require('node:process');
3+
const waitOn = require('wait-on');
4+
5+
const processes = new Set();
6+
let shuttingDown = false;
7+
8+
function spawnProcess(command, args, name) {
9+
const child = spawn(command, args, {
10+
stdio: 'inherit',
11+
});
12+
13+
processes.add(child);
14+
15+
child.on('exit', (code, signal) => {
16+
processes.delete(child);
17+
18+
if (shuttingDown) {
19+
return;
20+
}
21+
22+
const exitCode = typeof code === 'number' ? code : 1;
23+
24+
if (exitCode === 0 && !signal) {
25+
console.error(`${name} exited unexpectedly.`);
26+
shutdown(1);
27+
} else {
28+
console.error(`${name} exited with code ${exitCode}${signal ? ` (signal: ${signal})` : ''}`);
29+
shutdown(exitCode || 1);
30+
}
31+
});
32+
33+
child.on('error', error => {
34+
if (shuttingDown) {
35+
return;
36+
}
37+
38+
console.error(`${name} failed to start`, error);
39+
shutdown(1);
40+
});
41+
42+
return child;
43+
}
44+
45+
function shutdown(code = 0) {
46+
if (shuttingDown) {
47+
return;
48+
}
49+
50+
shuttingDown = true;
51+
52+
for (const child of processes) {
53+
if (!child.killed) {
54+
child.kill('SIGINT');
55+
}
56+
}
57+
58+
process.exit(code);
59+
}
60+
61+
['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach(signal => {
62+
process.on(signal, () => shutdown(0));
63+
});
64+
65+
console.log('Starting App2...');
66+
spawnProcess('pnpm', ['--filter', 'loadable-react-18_app2', 'start'], 'App2');
67+
68+
waitOn({
69+
resources: ['http://localhost:3001/server/remoteEntry.js'],
70+
timeout: 180_000,
71+
})
72+
.then(() => {
73+
if (shuttingDown) {
74+
return;
75+
}
76+
77+
console.log('App2 is ready. Starting App1...');
78+
spawnProcess('pnpm', ['--filter', 'loadable-react-18_app1', 'start'], 'App1');
79+
})
80+
.catch(error => {
81+
console.error('Failed to detect App2 readiness', error);
82+
shutdown(1);
83+
});

0 commit comments

Comments
 (0)