Skip to content

Commit 55a56c3

Browse files
authored
feat: support MF preloadRemote through PrefetchPlugin (#1136)
* feat: initial version of prefetch plugin * fix: tests * feat: stable prefetch * chore: warn about depsRemote not implemented yet * chore: cleanup * chore: hide preload behind a flag * refactor: keep CorePlugin noop impl as fallback * chore: changeset
1 parent 996942f commit 55a56c3

11 files changed

Lines changed: 186 additions & 1 deletion

File tree

.changeset/perfect-dots-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@callstack/repack": minor
3+
---
4+
5+
Support Module Federation `preloadRemote` through `PrefetchPlugin`.

apps/tester-federation-v2/configs/rspack.host-app.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ export default (env) => {
7777
new rspack.IgnorePlugin({
7878
resourceRegExp: /^@react-native-masked-view/,
7979
}),
80+
new rspack.DefinePlugin({
81+
__WITH_PRELOAD__:
82+
process.env.WITH_PRELOAD === 'true' ||
83+
process.env.WITH_PRELOAD === '1',
84+
}),
8085
],
8186
};
8287
};

apps/tester-federation-v2/ios/Podfile.lock

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,30 @@ PODS:
17851785
- ReactTestApp-DevSupport (4.3.3):
17861786
- React-Core
17871787
- React-jsi
1788+
- RNCAsyncStorage (1.24.0):
1789+
- DoubleConversion
1790+
- glog
1791+
- hermes-engine
1792+
- RCT-Folly (= 2024.11.18.00)
1793+
- RCTRequired
1794+
- RCTTypeSafety
1795+
- React-Core
1796+
- React-debug
1797+
- React-Fabric
1798+
- React-featureflags
1799+
- React-graphics
1800+
- React-hermes
1801+
- React-ImageManager
1802+
- React-jsi
1803+
- React-NativeModulesApple
1804+
- React-RCTFabric
1805+
- React-renderercss
1806+
- React-rendererdebug
1807+
- React-utils
1808+
- ReactCodegen
1809+
- ReactCommon/turbomodule/bridging
1810+
- ReactCommon/turbomodule/core
1811+
- Yoga
17881812
- RNScreens (4.10.0):
17891813
- DoubleConversion
17901814
- glog
@@ -1918,6 +1942,7 @@ DEPENDENCIES:
19181942
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
19191943
- "ReactNativeHost (from `../../../node_modules/.pnpm/react-native-test-app@4.3.3_react-native@0.79.1_@babel+core@7.25.2_@react-native-community+cl_kcnkti3wkp73kvg3lvpmdgswpy/node_modules/@rnx-kit/react-native-host`)"
19201944
- ReactTestApp-DevSupport (from `../node_modules/react-native-test-app`)
1945+
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
19211946
- RNScreens (from `../node_modules/react-native-screens`)
19221947
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
19231948

@@ -2075,6 +2100,8 @@ EXTERNAL SOURCES:
20752100
:path: "../../../node_modules/.pnpm/react-native-test-app@4.3.3_react-native@0.79.1_@babel+core@7.25.2_@react-native-community+cl_kcnkti3wkp73kvg3lvpmdgswpy/node_modules/@rnx-kit/react-native-host"
20762101
ReactTestApp-DevSupport:
20772102
:path: "../node_modules/react-native-test-app"
2103+
RNCAsyncStorage:
2104+
:path: "../node_modules/@react-native-async-storage/async-storage"
20782105
RNScreens:
20792106
:path: "../node_modules/react-native-screens"
20802107
Yoga:
@@ -2155,6 +2182,7 @@ SPEC CHECKSUMS:
21552182
ReactCommon: 9f975582dc535de1de110bdb46d4553140a77541
21562183
ReactNativeHost: e66210ecf04fd35d3d47970b7b33363d4933d0e4
21572184
ReactTestApp-DevSupport: ba564d9c3503d107cd6508a22681a19f8af34e81
2185+
RNCAsyncStorage: aa2fec76310ebe0c7fe159a26755e099170116bb
21582186
RNScreens: c5c07a86e4088ce92f0d3854082250dfa9c61f75
21592187
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
21602188
SwiftyRSA: 8c6dd1ea7db1b8dc4fb517a202f88bb1354bc2c6

apps/tester-federation-v2/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"dependencies": {
1414
"@callstack/repack": "workspace:*",
1515
"@module-federation/enhanced": "0.12.0",
16+
"@module-federation/runtime": "0.12.0",
17+
"@react-native-async-storage/async-storage": "^1.23.1",
1618
"@react-navigation/native": "^6.1.18",
1719
"@react-navigation/native-stack": "^6.10.1",
1820
"react": "catalog:",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
1+
import { ScriptManager } from '@callstack/repack/client';
2+
import { preloadRemote } from '@module-federation/runtime';
3+
import AsyncStorage from '@react-native-async-storage/async-storage';
14
import { AppRegistry } from 'react-native';
25

36
import { components } from '../../app.json';
47
import App from './App';
58

9+
if (__WITH_PRELOAD__) {
10+
// enable caching of scripts in the AsyncStorage
11+
ScriptManager.shared.setStorage(AsyncStorage);
12+
13+
// preload eagerly on startup
14+
// you can kill the dev server before going to the mini app screen
15+
// and it will still work because of the assets being present in cache
16+
ScriptManager.shared
17+
// invalidate cache to make sure we fetch the latest assets
18+
.invalidateScripts()
19+
// preload the MiniApp remote entry and all its assets
20+
.then(() => {
21+
return preloadRemote([
22+
{ nameOrAlias: 'MiniApp', resourceCategory: 'sync', depsRemote: false },
23+
]);
24+
})
25+
.then(() => {
26+
console.log('preloaded MiniApp assets');
27+
})
28+
.catch((e) => {
29+
// preloadRemote will fail if the remote entry is not a manifest
30+
console.error('error preloading MiniApp assets');
31+
console.error(e);
32+
});
33+
34+
ScriptManager.shared.on('prefetching', (script) => {
35+
console.debug('prefetching', script.locator.uniqueId);
36+
});
37+
}
38+
639
AppRegistry.registerComponent(components[0].appKey, () => App);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from '../dist/modules/FederationRuntimePlugins/PrefetchPlugin.js';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from '../dist/modules/FederationRuntimePlugins/PrefetchPlugin.js';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
2+
import type * as RepackClient from '../ScriptManager/index.js';
3+
4+
interface PrefetchAsset {
5+
name: string;
6+
remoteName: string;
7+
url: string;
8+
}
9+
10+
function getAssetName(asset: string): string {
11+
// remove the extension from the asset name
12+
return asset.split('.')[0];
13+
}
14+
15+
function getAssetUrl(asset: string) {
16+
// create placeholder reference url for the asset
17+
return 'prefetch:///' + asset;
18+
}
19+
20+
function prefetchAsset(asset: PrefetchAsset) {
21+
const client = require('../ScriptManager/index.js') as typeof RepackClient;
22+
const { ScriptManager, getWebpackContext } = client;
23+
24+
// caller should be undefined when fetching/loading the remote entry container
25+
const caller = asset.name === asset.remoteName ? undefined : asset.remoteName;
26+
27+
return ScriptManager.shared.prefetchScript(
28+
asset.name,
29+
caller,
30+
getWebpackContext(),
31+
asset.url
32+
);
33+
}
34+
35+
const RepackPrefetchPlugin: () => FederationRuntimePlugin = () => ({
36+
name: 'repack-prefetch-plugin',
37+
generatePreloadAssets: async (args) => {
38+
const preloadConfig = args.preloadOptions.preloadConfig;
39+
const remoteName = preloadConfig.nameOrAlias;
40+
const remoteSnapshot = args.remoteSnapshot;
41+
42+
if (preloadConfig.depsRemote !== false) {
43+
console.warn(
44+
'[RepackPrefetchPlugin] ' +
45+
'The depsRemote configuration option is not implemented yet. ' +
46+
'This setting will be ignored and will have no effect. ' +
47+
'You can hide this warning by setting depsRemote explicitly to false.'
48+
);
49+
}
50+
51+
function handleAssets(assets: string[]): PrefetchAsset[] {
52+
return assets.map((asset) => ({
53+
name: getAssetName(asset),
54+
remoteName,
55+
url: getAssetUrl(asset),
56+
}));
57+
}
58+
59+
let assets: PrefetchAsset[] = [];
60+
61+
if ('modules' in remoteSnapshot) {
62+
for (const exposedModule of remoteSnapshot.modules) {
63+
if (preloadConfig.exposes) {
64+
if (!preloadConfig.exposes.includes(exposedModule.moduleName)) {
65+
continue;
66+
}
67+
}
68+
69+
if (preloadConfig.resourceCategory === 'all') {
70+
assets.push(...handleAssets(exposedModule.assets.js.async));
71+
assets.push(...handleAssets(exposedModule.assets.js.sync));
72+
} else if (preloadConfig.resourceCategory === 'sync') {
73+
assets.push(...handleAssets(exposedModule.assets.js.sync));
74+
}
75+
}
76+
77+
if (preloadConfig.filter) {
78+
assets = assets.filter((asset) => preloadConfig.filter!(asset.name));
79+
}
80+
81+
assets.unshift({
82+
name: remoteSnapshot.globalName,
83+
remoteName: remoteSnapshot.globalName,
84+
url: getAssetUrl(remoteSnapshot.remoteEntry),
85+
});
86+
}
87+
88+
await Promise.all(assets.map(prefetchAsset));
89+
90+
// noop for compatibility
91+
return Promise.resolve({
92+
cssAssets: [],
93+
jsAssetsWithoutEntry: [],
94+
entryAssets: [],
95+
});
96+
},
97+
});
98+
99+
export default RepackPrefetchPlugin;

packages/repack/src/plugins/ModuleFederationPluginV2.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export class ModuleFederationPluginV2 implements RspackPluginInstance {
109109
this.defaultRuntimePlugins = defaultRuntimePlugins ?? [
110110
'@callstack/repack/mf/core-plugin',
111111
'@callstack/repack/mf/resolver-plugin',
112+
'@callstack/repack/mf/prefetch-plugin',
112113
];
113114
}
114115

packages/repack/src/plugins/__tests__/ModuleFederationPluginV2.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const corePluginPath = require.resolve('@callstack/repack/mf/core-plugin');
3030
const resolverPluginPath = require.resolve(
3131
'@callstack/repack/mf/resolver-plugin'
3232
);
33+
const prefetchPluginPath = require.resolve(
34+
'@callstack/repack/mf/prefetch-plugin'
35+
);
3336

3437
describe('ModuleFederationPlugin', () => {
3538
afterEach(() => {
@@ -164,7 +167,8 @@ describe('ModuleFederationPlugin', () => {
164167
const config = mockPlugin.mock.calls[0][0];
165168
expect(config.runtimePlugins).toContain(corePluginPath);
166169
expect(config.runtimePlugins).toContain(resolverPluginPath);
167-
expect(config.runtimePlugins).toHaveLength(2);
170+
expect(config.runtimePlugins).toContain(prefetchPluginPath);
171+
expect(config.runtimePlugins).toHaveLength(3);
168172
});
169173

170174
it('should use loaded-first as default shareStrategy', () => {

0 commit comments

Comments
 (0)