diff --git a/.changeset/chilly-ravens-smile.md b/.changeset/chilly-ravens-smile.md
new file mode 100644
index 0000000000..9f201e2b62
--- /dev/null
+++ b/.changeset/chilly-ravens-smile.md
@@ -0,0 +1,28 @@
+---
+"@lynx-js/externals-loading-webpack-plugin": patch
+---
+
+Introduce `@lynx-js/externals-loading-webpack-plugin`.
+
+```js
+// webpack.config.js
+import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin';
+
+export default {
+ plugins: [
+ new ExternalsLoadingPlugin({
+ mainThreadChunks: ['index__main-thread'],
+ backgroundChunks: ['index'],
+ mainThreadLayer: 'main-thread',
+ backgroundLayer: 'background',
+ externals: {
+ lodash: {
+ url: 'http://lodash.lynx.bundle',
+ background: { sectionPath: 'background' },
+ mainThread: { sectionPath: 'main-thread' },
+ },
+ },
+ }),
+ ],
+};
+```
diff --git a/examples/react-externals/README.md b/examples/react-externals/README.md
new file mode 100644
index 0000000000..6082662561
--- /dev/null
+++ b/examples/react-externals/README.md
@@ -0,0 +1,16 @@
+# @lynx-js/example-react-externals
+
+In this example, we show:
+
+- Use `@lynx-js/lynx-bundle-rslib-config` to bundle ReactLynx runtime to a separate Lynx bundle.
+- Use `@lynx-js/lynx-bundle-rslib-config` to bundle a simple ReactLynx component library to a separate Lynx bundle.
+- Use `@lynx-js/externals-loading-webpack-plugin` to load ReactLynx runtime (sync) and component bundle (async).
+
+## Usage
+
+```bash
+pnpm build:reactlynx
+pnpm build:comp-lib
+pnpx http-server -p 8080 dist
+EXTERNAL_BUNDLE_PREFIX=http://${YOUR_IP_HERE}:8080 pnpm dev
+```
diff --git a/examples/react-externals/external-bundle/CompLib.tsx b/examples/react-externals/external-bundle/CompLib.tsx
new file mode 100644
index 0000000000..3c7068933a
--- /dev/null
+++ b/examples/react-externals/external-bundle/CompLib.tsx
@@ -0,0 +1 @@
+export { App } from '../src/App.js';
diff --git a/examples/react-externals/external-bundle/ReactLynx.ts b/examples/react-externals/external-bundle/ReactLynx.ts
new file mode 100644
index 0000000000..401d2ca086
--- /dev/null
+++ b/examples/react-externals/external-bundle/ReactLynx.ts
@@ -0,0 +1,10 @@
+export * as React from '@lynx-js/react';
+export * as ReactInternal from '@lynx-js/react/internal';
+export * as ReactLazyImport from '@lynx-js/react/experimental/lazy/import';
+export * as ReactLegacyRuntime from '@lynx-js/react/legacy-react-runtime';
+export * as ReactComponents from '@lynx-js/react/runtime-components';
+export * as ReactWorkletRuntime from '@lynx-js/react/worklet-runtime/bindings';
+export * as ReactDebug from '@lynx-js/react/debug';
+// @ts-expect-error preact is aliased by rspeedy
+// eslint-disable-next-line import/no-unresolved
+export * as Preact from 'preact';
diff --git a/examples/react-externals/lynx.config.js b/examples/react-externals/lynx.config.js
new file mode 100644
index 0000000000..98efc148f1
--- /dev/null
+++ b/examples/react-externals/lynx.config.js
@@ -0,0 +1,108 @@
+import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin';
+import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin';
+import { LAYERS, pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
+import { defineConfig } from '@lynx-js/rspeedy';
+
+const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS'];
+const EXTERNAL_BUNDLE_PREFIX = process.env['EXTERNAL_BUNDLE_PREFIX'] || '';
+
+export default defineConfig({
+ tools: {
+ rspack: {
+ plugins: [
+ new ExternalsLoadingPlugin({
+ mainThreadChunks: ['main__main-thread'],
+ backgroundChunks: ['main'],
+ mainThreadLayer: LAYERS.MAIN_THREAD,
+ backgroundLayer: LAYERS.BACKGROUND,
+ externals: {
+ '@lynx-js/react': {
+ libraryName: ['ReactLynx', 'React'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/internal': {
+ libraryName: ['ReactLynx', 'ReactInternal'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/experimental/lazy/import': {
+ libraryName: ['ReactLynx', 'ReactLazyImport'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/legacy-react-runtime': {
+ libraryName: ['ReactLynx', 'ReactLegacyRuntime'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/runtime-components': {
+ libraryName: ['ReactLynx', 'ReactComponents'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/worklet-runtime/bindings': {
+ libraryName: ['ReactLynx', 'ReactWorkletRuntime'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ '@lynx-js/react/debug': {
+ libraryName: ['ReactLynx', 'ReactDebug'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ preact: {
+ libraryName: ['ReactLynx', 'Preact'],
+ url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`,
+ background: { sectionPath: 'ReactLynx' },
+ mainThread: { sectionPath: 'ReactLynx__main-thread' },
+ async: false,
+ },
+ './App.js': {
+ libraryName: 'CompLib',
+ url: `${EXTERNAL_BUNDLE_PREFIX}/comp-lib.lynx.bundle`,
+ background: { sectionPath: 'CompLib' },
+ mainThread: { sectionPath: 'CompLib__main-thread' },
+ async: false,
+ },
+ },
+ }),
+ ],
+ },
+ },
+ plugins: [
+ pluginReactLynx(),
+ pluginQRCode({
+ schema(url) {
+ // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode
+ return `${url}?fullscreen=true`;
+ },
+ }),
+ ],
+ environments: {
+ web: {},
+ lynx: {
+ performance: {
+ profile: enableBundleAnalysis,
+ },
+ },
+ },
+ output: {
+ filenameHash: 'contenthash:8',
+ cleanDistPath: false,
+ },
+});
diff --git a/examples/react-externals/package.json b/examples/react-externals/package.json
new file mode 100644
index 0000000000..f04b9d960f
--- /dev/null
+++ b/examples/react-externals/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@lynx-js/example-react-externals",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "rspeedy build",
+ "build:comp-lib": "rslib build --config rslib-comp-lib.config.ts",
+ "build:reactlynx": "rslib build --config rslib-reactlynx.config.ts",
+ "dev": "rspeedy dev"
+ },
+ "dependencies": {
+ "@lynx-js/react": "workspace:*"
+ },
+ "devDependencies": {
+ "@lynx-js/externals-loading-webpack-plugin": "workspace:*",
+ "@lynx-js/lynx-bundle-rslib-config": "workspace:*",
+ "@lynx-js/preact-devtools": "^5.0.1-cf9aef5",
+ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*",
+ "@lynx-js/react-alias-rsbuild-plugin": "workspace:*",
+ "@lynx-js/react-rsbuild-plugin": "workspace:*",
+ "@lynx-js/react-webpack-plugin": "workspace:*",
+ "@lynx-js/rspeedy": "workspace:*",
+ "@lynx-js/types": "3.4.11",
+ "@types/react": "^18.3.25"
+ }
+}
diff --git a/examples/react-externals/rslib-comp-lib.config.ts b/examples/react-externals/rslib-comp-lib.config.ts
new file mode 100644
index 0000000000..e77e81cfaa
--- /dev/null
+++ b/examples/react-externals/rslib-comp-lib.config.ts
@@ -0,0 +1,79 @@
+import { createRequire } from 'node:module';
+import path from 'node:path';
+
+import {
+ LAYERS,
+ defineExternalBundleRslibConfig,
+} from '@lynx-js/lynx-bundle-rslib-config';
+import { pluginReactAlias } from '@lynx-js/react-alias-rsbuild-plugin';
+import { ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin';
+
+const require = createRequire(import.meta.url);
+const reactLynxDir = path.dirname(
+ require.resolve('@lynx-js/react/package.json'),
+);
+
+export default defineExternalBundleRslibConfig({
+ id: 'comp-lib',
+ tools: {
+ rspack: {
+ module: {
+ rules: [
+ {
+ test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/,
+ issuerLayer: LAYERS.BACKGROUND,
+ loader: ReactWebpackPlugin.loaders.BACKGROUND,
+ },
+ {
+ test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/,
+ issuerLayer: LAYERS.MAIN_THREAD,
+ loader: ReactWebpackPlugin.loaders.MAIN_THREAD,
+ },
+ ],
+ },
+ },
+ },
+ source: {
+ entry: {
+ 'CompLib': './external-bundle/CompLib.tsx',
+ },
+ define: {
+ '__DEV__': 'false',
+ 'process.env.NODE_ENV': '"production"',
+ '__FIRST_SCREEN_SYNC_TIMING__': '"immediately"',
+ '__ENABLE_SSR__': 'false',
+ '__PROFILE__': 'false',
+ '__EXTRACT_STR__': 'false',
+ },
+ },
+ plugins: [
+ pluginReactAlias({
+ LAYERS,
+ rootPath: reactLynxDir,
+ }),
+ ],
+ output: {
+ cleanDistPath: false,
+ dataUriLimit: Number.POSITIVE_INFINITY,
+ externals: {
+ '@lynx-js/react': ['ReactLynx', 'React'],
+ '@lynx-js/react/internal': ['ReactLynx', 'ReactInternal'],
+ '@lynx-js/react/experimental/lazy/import': [
+ 'ReactLynx',
+ 'ReactLazyImport',
+ ],
+ '@lynx-js/react/legacy-react-runtime': [
+ 'ReactLynx',
+ 'ReactLegacyRuntime',
+ ],
+ '@lynx-js/react/runtime-components': ['ReactLynx', 'ReactComponents'],
+ '@lynx-js/react/worklet-runtime/bindings': [
+ 'ReactLynx',
+ 'ReactWorkletRuntime',
+ ],
+ '@lynx-js/react/debug': ['ReactLynx', 'ReactDebug'],
+ 'preact': ['ReactLynx', 'Preact'],
+ },
+ minify: false,
+ },
+});
diff --git a/examples/react-externals/rslib-reactlynx.config.ts b/examples/react-externals/rslib-reactlynx.config.ts
new file mode 100644
index 0000000000..097615dfd1
--- /dev/null
+++ b/examples/react-externals/rslib-reactlynx.config.ts
@@ -0,0 +1,58 @@
+import { createRequire } from 'node:module';
+import path from 'node:path';
+
+import {
+ LAYERS,
+ defineExternalBundleRslibConfig,
+} from '@lynx-js/lynx-bundle-rslib-config';
+import { pluginReactAlias } from '@lynx-js/react-alias-rsbuild-plugin';
+import { ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin';
+
+const require = createRequire(import.meta.url);
+const reactLynxDir = path.dirname(
+ require.resolve('@lynx-js/react/package.json'),
+);
+export default defineExternalBundleRslibConfig({
+ id: 'react',
+ tools: {
+ rspack: {
+ module: {
+ rules: [
+ {
+ test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/,
+ issuerLayer: LAYERS.BACKGROUND,
+ loader: ReactWebpackPlugin.loaders.BACKGROUND,
+ },
+ {
+ issuerLayer: LAYERS.MAIN_THREAD,
+ test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/,
+ loader: ReactWebpackPlugin.loaders.MAIN_THREAD,
+ },
+ ],
+ },
+ },
+ },
+ source: {
+ entry: {
+ 'ReactLynx': './external-bundle/ReactLynx.ts',
+ },
+ define: {
+ '__DEV__': 'false',
+ 'process.env.NODE_ENV': '"production"',
+ '__FIRST_SCREEN_SYNC_TIMING__': '"immediately"',
+ '__ENABLE_SSR__': 'false',
+ '__PROFILE__': 'false',
+ '__EXTRACT_STR__': 'false',
+ },
+ },
+ plugins: [
+ pluginReactAlias({
+ LAYERS,
+ rootPath: reactLynxDir,
+ }),
+ ],
+ output: {
+ cleanDistPath: false,
+ minify: false,
+ },
+});
diff --git a/examples/react-externals/src/App.css b/examples/react-externals/src/App.css
new file mode 100644
index 0000000000..650e423282
--- /dev/null
+++ b/examples/react-externals/src/App.css
@@ -0,0 +1,119 @@
+:root {
+ background-color: #000;
+ --color-text: #fff;
+}
+
+.Background {
+ position: fixed;
+ background: radial-gradient(
+ 71.43% 62.3% at 46.43% 36.43%,
+ rgba(18, 229, 229, 0) 15%,
+ rgba(239, 155, 255, 0.3) 56.35%,
+ #ff6448 100%
+ );
+ box-shadow: 0px 12.93px 28.74px 0px #ffd28db2 inset;
+ border-radius: 50%;
+ width: 200vw;
+ height: 200vw;
+ top: -60vw;
+ left: -14.27vw;
+ transform: rotate(15.25deg);
+}
+
+.App {
+ position: relative;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+text {
+ color: var(--color-text);
+}
+
+.Banner {
+ flex: 5;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+.Logo {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 8px;
+}
+
+.Logo--react {
+ width: 100px;
+ height: 100px;
+ animation: Logo--spin infinite 20s linear;
+}
+
+.Logo--lynx {
+ width: 100px;
+ height: 100px;
+ animation: Logo--shake infinite 0.5s ease;
+}
+
+@keyframes Logo--spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes Logo--shake {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(0.9);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.Content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.Arrow {
+ width: 24px;
+ height: 24px;
+}
+
+.Title {
+ font-size: 36px;
+ font-weight: 700;
+}
+
+.Subtitle {
+ font-style: italic;
+ font-size: 22px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.Description {
+ font-size: 20px;
+ color: rgba(255, 255, 255, 0.85);
+ margin: 15rpx;
+}
+
+.Hint {
+ font-size: 12px;
+ margin: 5px;
+ color: rgba(255, 255, 255, 0.65);
+}
diff --git a/examples/react-externals/src/App.tsx b/examples/react-externals/src/App.tsx
new file mode 100644
index 0000000000..5da30edede
--- /dev/null
+++ b/examples/react-externals/src/App.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useState } from '@lynx-js/react';
+
+import './App.css';
+import arrow from './assets/arrow.png';
+import lynxLogo from './assets/lynx-logo.png';
+import reactLynxLogo from './assets/react-logo.png';
+
+export function App() {
+ const [alterLogo, setAlterLogo] = useState(false);
+
+ useEffect(() => {
+ console.info('Hello, ReactLynx');
+ }, []);
+
+ const onTap = useCallback(() => {
+ 'background-only';
+ setAlterLogo(prevAlterLogo => !prevAlterLogo);
+ }, []);
+
+ return (
+
+
+
+
+
+ {alterLogo
+ ?
+ : }
+
+ React
+ on Lynx
+
+
+
+ Tap the logo and have fun!
+
+ Edit
+ {' src/App.tsx '}
+
+ to see updates!
+
+
+
+
+
+ );
+}
diff --git a/examples/react-externals/src/assets/arrow.png b/examples/react-externals/src/assets/arrow.png
new file mode 100644
index 0000000000..435c8ad4d1
Binary files /dev/null and b/examples/react-externals/src/assets/arrow.png differ
diff --git a/examples/react-externals/src/assets/lynx-logo.png b/examples/react-externals/src/assets/lynx-logo.png
new file mode 100644
index 0000000000..fe44bf0e3c
Binary files /dev/null and b/examples/react-externals/src/assets/lynx-logo.png differ
diff --git a/examples/react-externals/src/assets/react-logo.png b/examples/react-externals/src/assets/react-logo.png
new file mode 100644
index 0000000000..4ad12a6b55
Binary files /dev/null and b/examples/react-externals/src/assets/react-logo.png differ
diff --git a/examples/react-externals/src/index.tsx b/examples/react-externals/src/index.tsx
new file mode 100644
index 0000000000..d0d893b024
--- /dev/null
+++ b/examples/react-externals/src/index.tsx
@@ -0,0 +1,17 @@
+import '@lynx-js/react/debug';
+import { root } from '@lynx-js/react';
+
+import { App } from './App.js';
+
+// We have to manually import the css now
+// TODO: load css from external bundle
+// when it is supported in Lynx engine
+import './App.css';
+
+root.render(
+ ,
+);
+
+if (import.meta.webpackHot) {
+ import.meta.webpackHot.accept();
+}
diff --git a/examples/react-externals/src/rspeedy-env.d.ts b/examples/react-externals/src/rspeedy-env.d.ts
new file mode 100644
index 0000000000..1c813a68b0
--- /dev/null
+++ b/examples/react-externals/src/rspeedy-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/react-externals/tsconfig.json b/examples/react-externals/tsconfig.json
new file mode 100644
index 0000000000..8f6d4de332
--- /dev/null
+++ b/examples/react-externals/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "@lynx-js/react",
+ "noEmit": true,
+
+ "allowJs": true,
+ "checkJs": true,
+ "isolatedDeclarations": false,
+ },
+ "include": ["src", "external-bundle", "lynx.config.js", "rslib-comp-lib.config.ts", "rslib-reactlynx.config.ts"],
+ "references": [
+ { "path": "../../packages/react/tsconfig.json" },
+ { "path": "../../packages/rspeedy/core/tsconfig.build.json" },
+ { "path": "../../packages/rspeedy/plugin-qrcode/tsconfig.build.json" },
+ { "path": "../../packages/rspeedy/plugin-react/tsconfig.build.json" },
+ ],
+}
diff --git a/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md b/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md
index 44dc5394cb..cc2e267015 100644
--- a/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md
+++ b/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md
@@ -14,8 +14,10 @@ import type { RslibConfig } from '@rslib/core';
// @public
export const defaultExternalBundleLibConfig: LibConfig;
+// Warning: (ae-forgotten-export) The symbol "ExternalBundleLibConfig" needs to be exported by the entry point index.d.ts
+//
// @public
-export function defineExternalBundleRslibConfig(userLibConfig: LibConfig, encodeOptions?: EncodeOptions): RslibConfig;
+export function defineExternalBundleRslibConfig(userLibConfig: ExternalBundleLibConfig, encodeOptions?: EncodeOptions): RslibConfig;
// @public
export interface EncodeOptions {
diff --git a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts
index 81dd438f1e..e22fa1ebde 100644
--- a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts
+++ b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts
@@ -75,6 +75,45 @@ export const defaultExternalBundleLibConfig: LibConfig = {
},
}
+export type Externals = Record
+
+export type LibOutputConfig = Required['output']
+
+export interface OutputConfig extends LibOutputConfig {
+ externals?: Externals
+}
+
+export interface ExternalBundleLibConfig extends LibConfig {
+ output?: OutputConfig
+}
+
+function transformExternals(
+ externals?: Externals,
+): Required['externals'] {
+ if (!externals) return {}
+
+ return function({ request, contextInfo }, callback) {
+ if (!request) return callback()
+ const libraryName = externals[request]
+ if (!libraryName) return callback()
+
+ if (contextInfo?.issuerLayer === LAYERS.MAIN_THREAD) {
+ callback(undefined, [
+ 'globalThis',
+ 'lynx_ex',
+ ...(Array.isArray(libraryName) ? libraryName : [libraryName]),
+ ], 'var')
+ } else {
+ callback(undefined, [
+ 'lynxCoreInject',
+ 'tt',
+ 'lynx_ex',
+ ...(Array.isArray(libraryName) ? libraryName : [libraryName]),
+ ], 'var')
+ }
+ }
+}
+
/**
* Get the rslib config for building Lynx external bundles.
*
@@ -146,7 +185,7 @@ export const defaultExternalBundleLibConfig: LibConfig = {
* Then you can use `lynx.loadScript('utils', { bundleName: 'utils-lib-bundle-url' })` in background thread and `lynx.loadScript('utils__main-thread', { bundleName: 'utils-lib-bundle-url' })` in main-thread.
*/
export function defineExternalBundleRslibConfig(
- userLibConfig: LibConfig,
+ userLibConfig: ExternalBundleLibConfig,
encodeOptions: EncodeOptions = {},
): RslibConfig {
return {
@@ -154,7 +193,13 @@ export function defineExternalBundleRslibConfig(
// eslint-disable-next-line import/namespace
rsbuild.mergeRsbuildConfig(
defaultExternalBundleLibConfig,
- userLibConfig,
+ {
+ ...userLibConfig,
+ output: {
+ ...userLibConfig.output,
+ externals: transformExternals(userLibConfig.output?.externals),
+ },
+ },
),
],
plugins: [
diff --git a/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md b/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md
new file mode 100644
index 0000000000..1d683b7972
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md
@@ -0,0 +1 @@
+# @lynx-js/externals-loading-webpack-plugin
diff --git a/packages/webpack/externals-loading-webpack-plugin/README.md b/packages/webpack/externals-loading-webpack-plugin/README.md
new file mode 100644
index 0000000000..264ad94f04
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/README.md
@@ -0,0 +1,3 @@
+@lynx-js/externals-loading-webpack-plugin
+
+A webpack plugin to support loading externals in Lynx.
diff --git a/packages/webpack/externals-loading-webpack-plugin/api-extractor.json b/packages/webpack/externals-loading-webpack-plugin/api-extractor.json
new file mode 100644
index 0000000000..65b0c69ca6
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/api-extractor.json
@@ -0,0 +1,6 @@
+/**
+ * Config file for API Extractor. For more info, please visit: https://api-extractor.com
+ */
+{
+ "extends": "../../../api-extractor.json",
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md b/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md
new file mode 100644
index 0000000000..05bf339102
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md
@@ -0,0 +1,37 @@
+## API Report File for "@lynx-js/externals-loading-webpack-plugin"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { Compiler } from '@rspack/core';
+
+// @public
+export class ExternalsLoadingPlugin {
+ constructor(options: ExternalsLoadingPluginOptions);
+ // (undocumented)
+ apply(compiler: Compiler): void;
+}
+
+// @public
+export interface ExternalsLoadingPluginOptions {
+ backgroundChunks: string[];
+ backgroundLayer: string;
+ externals: Record;
+ mainThreadChunks: string[];
+ mainThreadLayer: string;
+}
+
+// @public
+export interface LayerOptions {
+ sectionPath: string;
+}
+
+```
diff --git a/packages/webpack/externals-loading-webpack-plugin/package.json b/packages/webpack/externals-loading-webpack-plugin/package.json
new file mode 100644
index 0000000000..1ba1bc0096
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@lynx-js/externals-loading-webpack-plugin",
+ "version": "0.0.0",
+ "description": "A webpack plugin to load lynx external bundles.",
+ "keywords": [
+ "webpack",
+ "Lynx"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/lynx-family/lynx-stack.git",
+ "directory": "packages/webpack/externals-loading-webpack-plugin"
+ },
+ "license": "Apache-2.0",
+ "author": {
+ "name": "Hengchang Lu",
+ "email": "luhengchang228@gmail.com"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./lib/index.d.ts",
+ "import": "./lib/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "types": "./lib/index.d.ts",
+ "files": [
+ "lib",
+ "!lib/**/*.js.map",
+ "CHANGELOG.md",
+ "README.md"
+ ],
+ "scripts": {
+ "api-extractor": "api-extractor run --verbose",
+ "test": "vitest"
+ },
+ "devDependencies": {
+ "@lynx-js/test-tools": "workspace:*",
+ "@rspack/core": "catalog:rspack",
+ "foo": "./test/foo"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/src/index.ts b/packages/webpack/externals-loading-webpack-plugin/src/index.ts
new file mode 100644
index 0000000000..9f4d635bf7
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/src/index.ts
@@ -0,0 +1,483 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+/**
+ * @packageDocumentation
+ *
+ * A webpack plugin to load externals in Lynx. Use `lynx.fetchBundle()` and `lynx.loadScript()` API to load and parse the externals.
+ *
+ * @remarks
+ * Requires Lynx version 3.5 or later.
+ */
+
+import type {
+ Compiler,
+ ExternalItemFunctionData,
+ ExternalItemValue,
+ ExternalsType,
+} from '@rspack/core';
+
+/**
+ * The options of the `ExternalsLoadingPlugin`.
+ *
+ * @public
+ */
+export interface ExternalsLoadingPluginOptions {
+ /**
+ * The chunk names to be considered as main thread chunks.
+ */
+ mainThreadChunks: string[];
+
+ /**
+ * The chunk names to be considered as background chunks.
+ */
+ backgroundChunks: string[];
+
+ /**
+ * The name of the main thread layer.
+ */
+ mainThreadLayer: string;
+
+ /**
+ * The name of the background layer.
+ */
+ backgroundLayer: string;
+
+ /**
+ * Specify the externals to be loaded. The externals should be Lynx Bundles.
+ *
+ * @example
+ *
+ * Load `lodash` library in background layer and `main-thread` layer.
+ *
+ * ```js
+ * module.exports = {
+ * plugins: [
+ * new ExternalsLoadingPlugin({
+ * externals: {
+ * lodash: {
+ * url: 'http://lodash.lynx.bundle',
+ * background: { sectionPath: 'background' },
+ * mainThread: { sectionPath: 'mainThread' },
+ * },
+ * },
+ * }),
+ * ],
+ * };
+ * ```
+ *
+ * @example
+ *
+ * Load `lodash` library only in background layer.
+ *
+ * ```js
+ * module.exports = {
+ * plugins: [
+ * new ExternalsLoadingPlugin({
+ * externals: {
+ * lodash: {
+ * url: 'http://lodash.lynx.bundle',
+ * background: { sectionPath: 'background' }
+ * },
+ * },
+ * }),
+ * ],
+ * };
+ * ```
+ */
+ externals: Record<
+ string,
+ {
+ /**
+ * The bundle(lynx.bundle) url of the library. The library source should be placed in `customSections`.
+ */
+ url: string;
+
+ /**
+ * The name of the library. Same as https://webpack.js.org/configuration/externals/#string.
+ *
+ * By default, the library name is the same as the externals key. For example:
+ *
+ * The config
+ *
+ * ```js
+ * ExternalsLoadingPlugin({
+ * externals: {
+ * lodash: {
+ * url: '……',
+ * }
+ * }
+ * })
+ * ```
+ *
+ * Will generate the following webpack externals config:
+ *
+ * ```js
+ * externals: {
+ * lodash: '__webpack_require__.lynx_ex.lodash',
+ * }
+ * ```
+ *
+ * If one external bundle contains multiple modules, should set the same library name to ensure it's loaded only once. For example:
+ *
+ * ```js
+ * ExternalsLoadingPlugin({
+ * externals: {
+ * lodash: {
+ * libraryName: 'Lodash',
+ * url: '……',
+ * },
+ * 'lodash-es': {
+ * libraryName: 'Lodash',
+ * url: '……',
+ * }
+ * }
+ * })
+ * ```
+ * Will generate the following webpack externals config:
+ *
+ * ```js
+ * externals: {
+ * lodash: '__webpack_require__.lynx_ex.Lodash',
+ * 'lodash-es': '__webpack_require__.lynx_ex.Lodash',
+ * }
+ * ```
+ *
+ * You can pass an array to specify subpath of the external. Same as https://webpack.js.org/configuration/externals/#string-1. For example:
+ *
+ * ```js
+ * ExternalsLoadingPlugin({
+ * externals: {
+ * preact: {
+ * libraryName: ['ReactLynx', 'Preact'],
+ * url: '……',
+ * },
+ * }
+ * })
+ * ```
+ *
+ * Will generate the following webpack externals config:
+ *
+ * ```js
+ * externals: {
+ * preact: '__webpack_require__.lynx_ex.ReactLynx.Preact',
+ * }
+ * ```
+ *
+ * @defaultValue `undefined`
+ *
+ * @example `Lodash`
+ */
+ libraryName?: string | string[];
+
+ /**
+ * Whether the source should be loaded asynchronously or not.
+ *
+ * @defaultValue `true`
+ */
+ async?: boolean;
+
+ /**
+ * The options of the background layer.
+ *
+ * @defaultValue `undefined`
+ */
+ background?: LayerOptions;
+
+ /**
+ * The options of the main-thread layer.
+ *
+ * @defaultValue `undefined`
+ */
+ mainThread?: LayerOptions;
+
+ /**
+ * The wait time in milliseconds.
+ *
+ * @defaultValue `2000`
+ */
+ timeout?: number;
+ }
+ >;
+}
+
+/**
+ * The options of the background or main-thread layer.
+ *
+ * @public
+ */
+export interface LayerOptions {
+ /**
+ * The path in `customSections`.
+ */
+ sectionPath: string;
+}
+
+function getLynxExternalGlobal(layer: 'background' | 'mainThread') {
+ // We do not use `globalThis` in BTS to avoid issues when sharing js context is enabled
+ return `${
+ layer === 'background' ? 'lynxCoreInject.tt.lynx_ex' : 'globalThis.lynx_ex'
+ }`;
+}
+
+/**
+ * The webpack plugin to load lynx external bundles.
+ *
+ * @example
+ * ```js
+ * // webpack.config.js
+ * import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin';
+ *
+ * export default {
+ * plugins: [
+ * new ExternalsLoadingPlugin({
+ * mainThreadLayer: 'main-thread',
+ * backgroundLayer: 'background',
+ * mainThreadChunks: ['index__main-thread'],
+ * backgroundChunks: ['index'],
+ * externals: {
+ * 'lodash': {
+ * url: 'http://lodash.lynx.bundle',
+ * async: true,
+ * background: {
+ * sectionPath: 'background',
+ * },
+ * mainThread: {
+ * sectionPath: 'mainThread',
+ * },
+ * },
+ * }
+ * })
+ * ]
+ * }
+ * ```
+ *
+ * @public
+ */
+export class ExternalsLoadingPlugin {
+ constructor(private options: ExternalsLoadingPluginOptions) {}
+
+ apply(compiler: Compiler): void {
+ const { RuntimeModule } = compiler.webpack;
+
+ const externalsLoadingPluginOptions = this.options;
+
+ class ExternalsLoadingRuntimeModule extends RuntimeModule {
+ constructor() {
+ super('externals-loading-runtime');
+ }
+
+ override generate() {
+ if (!this.chunk?.name) {
+ return '';
+ }
+ if (!externalsLoadingPluginOptions.externals) {
+ return '';
+ }
+
+ if (
+ externalsLoadingPluginOptions.backgroundChunks.some(name =>
+ name === this.chunk!.name
+ )
+ ) {
+ return this.#genFetchAndLoadCode('background');
+ }
+
+ if (
+ externalsLoadingPluginOptions.mainThreadChunks.some(name =>
+ name === this.chunk!.name
+ )
+ ) {
+ return this.#genFetchAndLoadCode('mainThread');
+ }
+
+ return '';
+ }
+
+ #genFetchAndLoadCode(
+ layer: 'background' | 'mainThread',
+ ): string {
+ const fetchCode: string[] = [];
+ const asyncLoadCode: string[] = [];
+ const syncLoadCode: string[] = [];
+ // filter duplicate externals by libraryName or package name to avoid loading the same external multiple times. We keep the last one.
+ const externalsMap = new Map<
+ string | string[],
+ ExternalsLoadingPluginOptions['externals'][string]
+ >();
+ for (
+ const [pkgName, external] of Object.entries(
+ externalsLoadingPluginOptions.externals,
+ )
+ ) {
+ externalsMap.set(external.libraryName ?? pkgName, external);
+ }
+ const externals = Array.from(externalsMap.entries());
+
+ if (externals.length === 0) {
+ return '';
+ }
+ const runtimeGlobalsInit = `${getLynxExternalGlobal(layer)} = {};`;
+ const loadExternalFunc = `
+function createLoadExternalAsync(handler, sectionPath) {
+ return new Promise((resolve, reject) => {
+ handler.then((response) => {
+ if (response.code === 0) {
+ try {
+ const result = lynx.loadScript(sectionPath, { bundleName: response.url });
+ resolve(result)
+ } catch (error) {
+ reject(new Error('Failed to load script ' + sectionPath + ' in ' + response.url, { cause: error }))
+ }
+ } else {
+ reject(new Error('Failed to fetch external source ' + response.url + ' . The response is ' + JSON.stringify(response), { cause: response }));
+ }
+ })
+ })
+}
+function createLoadExternalSync(handler, sectionPath, timeout) {
+ const response = handler.wait(timeout)
+ if (response.code === 0) {
+ try {
+ const result = lynx.loadScript(sectionPath, { bundleName: response.url });
+ return result
+ } catch (error) {
+ throw new Error('Failed to load script ' + sectionPath + ' in ' + response.url, { cause: error })
+ }
+ } else {
+ throw new Error('Failed to fetch external source ' + response.url + ' . The response is ' + JSON.stringify(response), { cause: response })
+ }
+}
+`;
+
+ const hasUrlLibraryNamePairInjected = new Set();
+
+ for (let i = 0; i < externals.length; i++) {
+ const [pkgName, external] = externals[i]!;
+ const {
+ libraryName,
+ url,
+ async = true,
+ timeout: timeoutInMs = 2000,
+ } = external;
+ const layerOptions = external[layer];
+ // Lynx fetchBundle timeout is in seconds
+ const timeout = timeoutInMs / 1000;
+
+ if (!layerOptions?.sectionPath) {
+ continue;
+ }
+
+ const libraryNameWithDefault = libraryName ?? pkgName;
+ const libraryNameStr = Array.isArray(libraryNameWithDefault)
+ ? libraryNameWithDefault[0]
+ : libraryNameWithDefault;
+
+ const hash = `${url}-${libraryNameStr}`;
+ if (hasUrlLibraryNamePairInjected.has(hash)) {
+ continue;
+ }
+ hasUrlLibraryNamePairInjected.add(hash);
+
+ fetchCode.push(
+ `const handler${i} = lynx.fetchBundle(${JSON.stringify(url)}, {});`,
+ );
+
+ if (async) {
+ asyncLoadCode.push(
+ `${getLynxExternalGlobal(layer)}[${
+ JSON.stringify(libraryNameStr)
+ }] = createLoadExternalAsync(handler${i}, ${
+ JSON.stringify(layerOptions.sectionPath)
+ });`,
+ );
+ continue;
+ }
+
+ syncLoadCode.push(
+ `${getLynxExternalGlobal(layer)}[${
+ JSON.stringify(libraryNameStr)
+ }] = createLoadExternalSync(handler${i}, ${
+ JSON.stringify(layerOptions.sectionPath)
+ }, ${timeout});`,
+ );
+ }
+
+ return [
+ runtimeGlobalsInit,
+ loadExternalFunc,
+ fetchCode,
+ asyncLoadCode,
+ syncLoadCode,
+ ].flat().join('\n');
+ }
+ }
+
+ compiler.hooks.environment.tap(ExternalsLoadingPlugin.name, () => {
+ compiler.options.externals = [
+ ...(Array.isArray(compiler.options.externals)
+ ? compiler.options.externals
+ : (typeof compiler.options.externals === 'undefined'
+ ? []
+ : [compiler.options.externals])),
+ this.#genExternalsConfig(),
+ ];
+ });
+
+ compiler.hooks.compilation.tap(
+ ExternalsLoadingRuntimeModule.name,
+ compilation => {
+ compilation.hooks.additionalTreeRuntimeRequirements
+ .tap(ExternalsLoadingRuntimeModule.name, (chunk) => {
+ compilation.addRuntimeModule(
+ chunk,
+ new ExternalsLoadingRuntimeModule(),
+ );
+ });
+ },
+ );
+ }
+
+ /**
+ * If the external is async, use `promise` external type; otherwise, use `var` external type.
+ */
+ #genExternalsConfig(): (
+ data: ExternalItemFunctionData,
+ callback: (
+ err?: Error,
+ result?: ExternalItemValue,
+ type?: ExternalsType,
+ ) => void,
+ ) => void {
+ const { externals, backgroundLayer, mainThreadLayer } = this.options;
+ const externalDeps = new Set(Object.keys(externals));
+
+ return ({ request, contextInfo }, callback) => {
+ const currentLayer = contextInfo?.issuerLayer === mainThreadLayer
+ ? 'mainThread'
+ : (contextInfo?.issuerLayer === backgroundLayer
+ ? 'background'
+ : undefined);
+ if (
+ request
+ && externalDeps.has(request)
+ && currentLayer
+ && externals[request]?.[currentLayer]
+ ) {
+ const isAsync = externals[request]?.async ?? true;
+ const libraryName = externals[request]?.libraryName ?? request;
+ return callback(
+ undefined,
+ [
+ getLynxExternalGlobal(currentLayer),
+ ...(Array.isArray(libraryName) ? libraryName : [libraryName]),
+ ],
+ isAsync ? 'promise' : undefined,
+ );
+ }
+ // Continue without externalizing the import
+ callback();
+ };
+ }
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts b/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts
new file mode 100644
index 0000000000..b0d75d72cc
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts
@@ -0,0 +1,11 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import path from 'node:path';
+
+import { describeCases } from '@lynx-js/test-tools';
+
+describeCases({
+ name: 'externals-loading',
+ casePath: path.join(__dirname, 'cases'),
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js
new file mode 100644
index 0000000000..d3fbd5307f
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js
@@ -0,0 +1,12 @@
+import { add } from 'foo';
+
+const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() =>
+ undefined
+);
+
+console.info(add(1, 2));
+
+it('should log 3', () => {
+ expect(consoleInfoMock).toBeCalledTimes(1);
+ expect(consoleInfoMock).toBeCalledWith(3);
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js
new file mode 100644
index 0000000000..631b857106
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js
@@ -0,0 +1,27 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: true,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ },
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js
new file mode 100644
index 0000000000..81aa1a4e60
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js
@@ -0,0 +1,31 @@
+import x from 'foo';
+import x2 from 'foo2';
+
+console.info(x);
+console.info(x2);
+
+it('should filter duplicate externals', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+
+ const background = fs.readFileSync(
+ path.resolve(__dirname, 'main:background.js'),
+ 'utf-8',
+ );
+ const mainThread = fs.readFileSync(
+ path.resolve(__dirname, 'main:main-thread.js'),
+ 'utf-8',
+ );
+ expect(
+ background.split(
+ 'lynxCoreInject.tt.lynx_ex["Foo"] '
+ + '= createLoadExternalSync(',
+ ).length - 1,
+ ).toBe(1);
+ expect(
+ mainThread.split(
+ 'globalThis.lynx_ex["Foo"] '
+ + '= createLoadExternalSync(',
+ ).length - 1,
+ ).toBe(1);
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js
new file mode 100644
index 0000000000..b2fbd4c765
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js
@@ -0,0 +1,49 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: false,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: false,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ 'foo2': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: false,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ },
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js
new file mode 100644
index 0000000000..90a1ffb7b8
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js
@@ -0,0 +1,28 @@
+import lodash from 'lodash';
+import x from 'foo';
+
+console.info(lodash);
+console.info(x);
+
+it('should external lodash and foo', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+
+ const background = fs.readFileSync(
+ path.resolve(__dirname, 'main:background.js'),
+ 'utf-8',
+ );
+ const mainThread = fs.readFileSync(
+ path.resolve(__dirname, 'main:main-thread.js'),
+ 'utf-8',
+ );
+
+ expect(background).toContain('module.exports ' + '= Lodash;');
+ expect(mainThread).toContain('module.exports ' + '= Lodash;');
+ expect(background).toContain(
+ 'module.exports ' + '= lynxCoreInject.tt.lynx_ex.Foo;',
+ );
+ expect(mainThread).toContain(
+ 'module.exports ' + '= globalThis.lynx_ex.Foo;',
+ );
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js
new file mode 100644
index 0000000000..6cee3becfd
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js
@@ -0,0 +1,30 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: false,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ },
+ },
+ {
+ lodash: 'Lodash',
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js
new file mode 100644
index 0000000000..7e134569f9
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js
@@ -0,0 +1,27 @@
+import { add } from 'foo';
+
+const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() =>
+ undefined
+);
+
+console.info(add(1, 2));
+
+it('should log 3', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+
+ const background = fs.readFileSync(
+ path.resolve(__dirname, 'main:background.js'),
+ 'utf-8',
+ );
+ const mainThread = fs.readFileSync(
+ path.resolve(__dirname, 'main:main-thread.js'),
+ 'utf-8',
+ );
+
+ expect(background).not.toContain('add' + '(a, b)');
+ expect(mainThread).toContain('add' + '(a, b)');
+
+ expect(consoleInfoMock).toBeCalledTimes(1);
+ expect(consoleInfoMock).toBeCalledWith(3);
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js
new file mode 100644
index 0000000000..dee1adf70c
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js
@@ -0,0 +1,24 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ async: true,
+ background: {
+ sectionPath: 'background',
+ },
+ },
+ },
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js
new file mode 100644
index 0000000000..abced9aafa
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js
@@ -0,0 +1,26 @@
+import { add } from 'foo';
+
+const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() =>
+ undefined
+);
+
+console.info(add(1, 2));
+
+it('should log 3', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const background = fs.readFileSync(
+ path.resolve(__dirname, 'main:background.js'),
+ 'utf-8',
+ );
+ const mainThread = fs.readFileSync(
+ path.resolve(__dirname, 'main:main-thread.js'),
+ 'utf-8',
+ );
+
+ expect(background).toContain('add' + '(a, b)');
+ expect(mainThread).not.toContain('add' + '(a, b)');
+
+ expect(consoleInfoMock).toBeCalledTimes(1);
+ expect(consoleInfoMock).toBeCalledWith(3);
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js
new file mode 100644
index 0000000000..ab4d27db75
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js
@@ -0,0 +1,23 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ 'foo': {
+ libraryName: 'Foo',
+ url: 'foo',
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ },
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js
new file mode 100644
index 0000000000..007a3e05cb
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js
@@ -0,0 +1,12 @@
+import { add } from '@lynx-js/foo';
+
+const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() =>
+ undefined
+);
+
+console.info(add(1, 2));
+
+it('should log 3', () => {
+ expect(consoleInfoMock).toBeCalledTimes(1);
+ expect(consoleInfoMock).toBeCalledWith(3);
+});
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js
new file mode 100644
index 0000000000..3ec8fd9f82
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js
@@ -0,0 +1,26 @@
+import { createConfig } from '../../../helpers/create-config.js';
+
+/** @type {import('@rspack/core').Configuration} */
+export default {
+ context: __dirname,
+ ...createConfig(
+ {
+ backgroundChunks: ['main:background'],
+ mainThreadChunks: ['main:main-thread'],
+ backgroundLayer: 'background',
+ mainThreadLayer: 'main-thread',
+ externals: {
+ '@lynx-js/foo': {
+ url: 'foo',
+ async: false,
+ background: {
+ sectionPath: 'background',
+ },
+ mainThread: {
+ sectionPath: 'mainThread',
+ },
+ },
+ },
+ },
+ ),
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs
new file mode 100644
index 0000000000..2fa53abe09
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs
@@ -0,0 +1,7 @@
+/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
+module.exports = {
+ bundlePath: [
+ 'main:main-thread.js',
+ 'main:background.js',
+ ],
+};
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js b/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js
new file mode 100644
index 0000000000..9fa3420657
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js
@@ -0,0 +1,6 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+export function add(a, b) {
+ return a + b;
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json b/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json
new file mode 100644
index 0000000000..ff5350bd63
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "foo",
+ "private": true
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js b/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js
new file mode 100644
index 0000000000..542ccefd9a
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js
@@ -0,0 +1,46 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { ExternalsLoadingPlugin } from '../../src';
+
+/**
+ * @param {string=} name - The name
+ * @param {string=} source - The source path
+ * @returns {Record} - The react entries.
+ */
+export function createEntries(name = 'main', source = './index.js') {
+ return {
+ [`${name}:main-thread`]: {
+ layer: 'main-thread',
+ import: source,
+ },
+ [`${name}:background`]: {
+ layer: 'background',
+ import: source,
+ },
+ };
+}
+
+/**
+ * @param {import('../../src').ExternalsLoadingPluginOptions} externalsLoadingPluginOptions ExternalsLoadingPlugin options
+ * @param {import("@rspack/core").Configuration['externals']} externals rspack externals config
+ *
+ * @returns {import("@rspack/core").Configuration} The default Rspack configuration.
+ */
+export function createConfig(externalsLoadingPluginOptions, externals = {}) {
+ return {
+ entry: createEntries(),
+ experiments: {
+ layers: true,
+ },
+ externals,
+ plugins: [
+ new ExternalsLoadingPlugin(
+ externalsLoadingPluginOptions,
+ ),
+ ],
+ output: {
+ filename: '[name].js',
+ },
+ };
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js b/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js
new file mode 100644
index 0000000000..db86d756ef
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js
@@ -0,0 +1,38 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { createRequire } from 'node:module';
+
+__injectGlobals(globalThis);
+
+const require = createRequire(import.meta.url);
+
+// eslint-disable-next-line import/no-commonjs
+const foo = require('../foo/index');
+
+const CustomSections = {
+ 'background': foo,
+ 'mainThread': foo,
+};
+
+function __injectGlobals(target) {
+ target.printLogger = process.argv.includes('--verbose');
+
+ target.lynx = {
+ fetchBundle: (url) => {
+ return {
+ wait: () => ({ url, code: 0, err: null }),
+ then: (callback) => callback({ url, code: 0, err: null }),
+ };
+ },
+ loadScript: (sectionPath) => {
+ const module = CustomSections[sectionPath] ?? {};
+ return module;
+ },
+ };
+
+ target.Lodash = {};
+
+ target.lynxCoreInject = {};
+ target.lynxCoreInject.tt = {};
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json b/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json
new file mode 100644
index 0000000000..9d130b6e32
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "./lib",
+ "baseUrl": "./",
+ "rootDir": "./src",
+ },
+ "include": ["src"],
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/tsconfig.json b/packages/webpack/externals-loading-webpack-plugin/tsconfig.json
new file mode 100644
index 0000000000..86cc1aca31
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": ["./tsconfig.build.json"],
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true,
+ },
+ "include": ["src", "test", "vitest.config.ts"],
+}
diff --git a/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts b/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts
new file mode 100644
index 0000000000..f44e98fe04
--- /dev/null
+++ b/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineProject } from 'vitest/config';
+import type { UserWorkspaceConfig } from 'vitest/config';
+
+const config: UserWorkspaceConfig = defineProject({
+ test: {
+ name: 'webpack/externals-loading',
+ setupFiles: ['test/helpers/setup-env.js'],
+ },
+});
+
+export default config;
diff --git a/packages/webpack/tsconfig.json b/packages/webpack/tsconfig.json
index 9aa9d8a006..66728b8887 100644
--- a/packages/webpack/tsconfig.json
+++ b/packages/webpack/tsconfig.json
@@ -14,6 +14,7 @@
{ "path": "./react-refresh-webpack-plugin/tsconfig.build.json" },
{ "path": "./webpack-runtime-globals/tsconfig.build.json" },
{ "path": "./cache-events-webpack-plugin/tsconfig.build.json" },
+ { "path": "./externals-loading-webpack-plugin/tsconfig.build.json" },
/** packages-end */
],
"include": [],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e89f0c0aa6..1034fc7f1c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,6 +242,43 @@ importers:
specifier: 0.0.0-experimental-0566679-20250709
version: 0.0.0-experimental-0566679-20250709
+ examples/react-externals:
+ dependencies:
+ '@lynx-js/react':
+ specifier: workspace:*
+ version: link:../../packages/react
+ devDependencies:
+ '@lynx-js/externals-loading-webpack-plugin':
+ specifier: workspace:*
+ version: link:../../packages/webpack/externals-loading-webpack-plugin
+ '@lynx-js/lynx-bundle-rslib-config':
+ specifier: workspace:*
+ version: link:../../packages/rspeedy/lynx-bundle-rslib-config
+ '@lynx-js/preact-devtools':
+ specifier: ^5.0.1-cf9aef5
+ version: 5.0.1
+ '@lynx-js/qrcode-rsbuild-plugin':
+ specifier: workspace:*
+ version: link:../../packages/rspeedy/plugin-qrcode
+ '@lynx-js/react-alias-rsbuild-plugin':
+ specifier: workspace:*
+ version: link:../../packages/rspeedy/plugin-react-alias
+ '@lynx-js/react-rsbuild-plugin':
+ specifier: workspace:*
+ version: link:../../packages/rspeedy/plugin-react
+ '@lynx-js/react-webpack-plugin':
+ specifier: workspace:*
+ version: link:../../packages/webpack/react-webpack-plugin
+ '@lynx-js/rspeedy':
+ specifier: workspace:*
+ version: link:../../packages/rspeedy/core
+ '@lynx-js/types':
+ specifier: 3.4.11
+ version: 3.4.11
+ '@types/react':
+ specifier: ^18.3.25
+ version: 18.3.25
+
examples/react-lazy-bundle:
dependencies:
'@lynx-js/react':
@@ -1259,6 +1296,18 @@ importers:
specifier: ^5.102.0
version: 5.102.0
+ packages/webpack/externals-loading-webpack-plugin:
+ devDependencies:
+ '@lynx-js/test-tools':
+ specifier: workspace:*
+ version: link:../test-tools
+ '@rspack/core':
+ specifier: 1.6.6
+ version: 1.6.6(@swc/helpers@0.5.17)
+ foo:
+ specifier: ./test/foo
+ version: link:test/foo
+
packages/webpack/react-refresh-webpack-plugin:
devDependencies:
'@lynx-js/react':