diff --git a/.changeset/friendly-planes-help.md b/.changeset/friendly-planes-help.md
new file mode 100644
index 000000000000..bab0da7175aa
--- /dev/null
+++ b/.changeset/friendly-planes-help.md
@@ -0,0 +1,6 @@
+---
+'@modern-js/app-tools': minor
+'@modern-js/runtime': minor
+---
+
+feat: add `source.enableAsyncPreEntry` for async entry scenarios
diff --git a/packages/document/docs/en/configure/app/source/enable-async-pre-entry.mdx b/packages/document/docs/en/configure/app/source/enable-async-pre-entry.mdx
new file mode 100644
index 000000000000..f0b1de41427b
--- /dev/null
+++ b/packages/document/docs/en/configure/app/source/enable-async-pre-entry.mdx
@@ -0,0 +1,30 @@
+---
+title: enableAsyncPreEntry
+---
+
+# source.enableAsyncPreEntry
+
+- **Type:** `boolean`
+- **Default:** `false`
+
+When enabled, Modern.js will inject the modules configured in `source.preEntry` to the top of the auto-generated entry file (`index.jsx`) in order.
+
+This option only takes effect when `source.enableAsyncEntry` is enabled. If async entry is not enabled, this behavior will be skipped and the original builder-side `source.preEntry` injection remains unchanged.
+
+This option is mainly designed to work with `source.enableAsyncEntry`: when async entry is enabled, the final build entry becomes `bootstrap.jsx`, and the builder-side `source.preEntry` may not be injected into the real entry code. With `source.enableAsyncPreEntry` enabled, `preEntry` will be injected into `index.jsx` (the real entry code), so it also works in async entry scenarios.
+
+Meanwhile, when both `source.enableAsyncEntry` and `source.enableAsyncPreEntry` are enabled, Modern.js will not pass `source.preEntry` into builder config to avoid duplicate injection or injection into an unexpected entry.
+
+## Example
+
+```ts title="modern.config.ts"
+import { defineConfig } from '@modern-js/app-tools';
+
+export default defineConfig({
+ source: {
+ enableAsyncEntry: true,
+ enableAsyncPreEntry: true,
+ preEntry: ['./src/pre-a.ts', './src/pre-b.ts'],
+ },
+});
+```
diff --git a/packages/document/docs/zh/configure/app/source/enable-async-pre-entry.mdx b/packages/document/docs/zh/configure/app/source/enable-async-pre-entry.mdx
new file mode 100644
index 000000000000..2da5f36b45ac
--- /dev/null
+++ b/packages/document/docs/zh/configure/app/source/enable-async-pre-entry.mdx
@@ -0,0 +1,77 @@
+---
+title: enableAsyncPreEntry
+---
+
+# source.enableAsyncPreEntry
+
+- **类型:** `boolean`
+- **默认值:** `false`
+
+开启后,Modern.js 会在自动生成的入口文件(`index.jsx`)最上方依次插入 `source.preEntry` 中配置的模块 import。
+
+该配置仅在 `source.enableAsyncEntry` 开启时生效;如果未开启异步入口,则不会启用该行为(并保持 `source.preEntry` 原有的构建器注入逻辑不受影响)。
+
+该配置主要用于配合 `source.enableAsyncEntry` 使用:当启用异步入口时,最终的构建 entry 会变为 `bootstrap.jsx`,这会导致构建器侧的 `source.preEntry` 不一定能注入到真正的入口代码中。开启 `source.enableAsyncPreEntry` 后,`preEntry` 会注入到 `index.jsx`(真正的入口代码)顶部,从而保证在异步入口场景下也能生效。
+
+同时,当 `source.enableAsyncPreEntry` 开启时,Modern.js 不会将 `source.preEntry` 传递给构建器配置,避免重复注入或注入到不期望的 entry 中。
+
+## 示例
+
+```ts title="modern.config.ts"
+import { defineConfig } from '@modern-js/app-tools';
+
+export default defineConfig({
+ source: {
+ enableAsyncEntry: true,
+ enableAsyncPreEntry: true,
+ preEntry: ['./src/pre-a.ts', './src/pre-b.ts'],
+ },
+});
+```
+
+## 编译效果
+
+在开启 `source.enableAsyncEntry` 后,Modern.js 会为每个入口生成一个异步边界文件 `bootstrap.jsx`,并由它去动态加载真正的入口代码 `index.jsx`。
+
+当同时开启 `source.enableAsyncPreEntry` 时,Modern.js 会把 `source.preEntry` 以 `import` 的形式注入到 **`index.jsx` 的最顶部**(而不是注入到 `bootstrap.jsx`),保证异步入口场景下 `preEntry` 一定会在入口代码之前执行。
+
+### 生成文件结构
+
+以单入口 `main` 为例,生成的文件结构大致如下(位于 `node_modules/.modern-js/main/`):
+
+```bash
+node_modules
+ └─ .modern-js
+ └─ main
+ ├─ bootstrap.jsx # 最终用于构建的 entry(异步边界)
+ ├─ index.jsx # 真正的入口代码(会被注入 preEntry import)
+ ├─ register.js
+ ├─ runtime-register.js
+ └─ runtime-global-context.js
+```
+
+### bootstrap.jsx
+
+`bootstrap.jsx` 的核心逻辑是通过 dynamic import 加载 `index.jsx`:
+
+```js title="node_modules/.modern-js/main/bootstrap.jsx"
+import(/* webpackChunkName: "async-main" */ './index');
+```
+
+### index.jsx(会注入 preEntry)
+
+开启 `source.enableAsyncPreEntry` 后,`index.jsx` 文件的最上方会被注入 `source.preEntry` 对应的 import,随后才是 Modern.js 生成的入口逻辑,例如:
+
+```js title="node_modules/.modern-js/main/index.jsx"
+import '@_modern_js_src/pre-a.ts';
+import '@_modern_js_src/pre-b.ts';
+
+import '@modern-js/runtime/registry/main';
+// ...后续为 Modern.js 自动生成的入口逻辑
+```
+
+其中 `@_modern_js_src` 是 Modern.js 内部用于指向项目 `src` 目录的别名(实际值会随项目 metaName 等信息变化)。
+
+### 与 builder 的关系
+
+当 `source.enableAsyncEntry` 与 `source.enableAsyncPreEntry` 同时开启时,Modern.js **不会再把 `source.preEntry` 传递给构建器配置**,避免构建器重复注入或注入到不期望的 entry 中。
diff --git a/packages/runtime/plugin-runtime/src/cli/code.ts b/packages/runtime/plugin-runtime/src/cli/code.ts
index 318cfaf72e3d..40640312a354 100644
--- a/packages/runtime/plugin-runtime/src/cli/code.ts
+++ b/packages/runtime/plugin-runtime/src/cli/code.ts
@@ -1,3 +1,5 @@
+///
+
import path from 'path';
import type {
AppToolsContext,
@@ -5,7 +7,7 @@ import type {
AppToolsNormalizedConfig,
} from '@modern-js/app-tools';
import type { Entrypoint } from '@modern-js/types';
-import { fs } from '@modern-js/utils';
+import { fs, formatImportPath } from '@modern-js/utils';
import {
ENTRY_BOOTSTRAP_FILE_NAME,
ENTRY_POINT_FILE_NAME,
@@ -20,6 +22,43 @@ import { resolveSSRMode } from './ssr/mode';
import * as template from './template';
import * as serverTemplate from './template.server';
+const normalizePreEntry = (preEntry: unknown): string[] => {
+ if (!preEntry) {
+ return [];
+ }
+ if (Array.isArray(preEntry)) {
+ return preEntry.filter(
+ (v): v is string => typeof v === 'string' && v.length > 0,
+ );
+ }
+ if (typeof preEntry === 'string') {
+ return preEntry ? [preEntry] : [];
+ }
+ return [];
+};
+
+const resolvePreEntryImportPath = ({
+ preEntry,
+ appDirectory,
+ srcDirectory,
+ internalSrcAlias,
+}: {
+ preEntry: string;
+ appDirectory: string;
+ srcDirectory: string;
+ internalSrcAlias: string;
+}) => {
+ const absPath = path.isAbsolute(preEntry)
+ ? preEntry
+ : path.resolve(appDirectory, preEntry);
+
+ // Prefer importing via the internal src alias to keep imports shorter and stable.
+ if (absPath.startsWith(srcDirectory)) {
+ return formatImportPath(absPath.replace(srcDirectory, internalSrcAlias));
+ }
+ return formatImportPath(absPath);
+};
+
export const generateCode = async (
entrypoints: Entrypoint[],
appContext: AppToolsContext,
@@ -27,8 +66,10 @@ export const generateCode = async (
hooks: AppToolsFeatureHooks,
) => {
const { mountId } = config.html;
- const { enableAsyncEntry } = config.source;
+ const { enableAsyncEntry, enableAsyncPreEntry, preEntry } = config.source;
+ const shouldInjectAsyncPreEntry = !!enableAsyncEntry && !!enableAsyncPreEntry;
const {
+ appDirectory,
runtimeConfigFile,
internalDirectory,
internalSrcAlias,
@@ -82,6 +123,28 @@ export const generateCode = async (
});
}
+ // Only works with `enableAsyncEntry`:
+ // inject `source.preEntry` to the top of the generated entry file
+ // (`index.jsx`), so it runs before the real entry code even when the
+ // build entry is `bootstrap.jsx`.
+ if (shouldInjectAsyncPreEntry) {
+ const preEntries = normalizePreEntry(preEntry);
+ if (preEntries.length > 0) {
+ const injected = preEntries
+ .map(item => {
+ const importPath = resolvePreEntryImportPath({
+ preEntry: item,
+ appDirectory,
+ srcDirectory,
+ internalSrcAlias,
+ });
+ return `import '${importPath}';`;
+ })
+ .join('\n');
+ indexCode = `${injected}\n${indexCode}`;
+ }
+ }
+
const indexFile = path.resolve(
internalDirectory,
`./${entryName}/${ENTRY_POINT_FILE_NAME}`,
diff --git a/packages/solutions/app-tools/src/builder/generator/createBuilderProviderConfig.ts b/packages/solutions/app-tools/src/builder/generator/createBuilderProviderConfig.ts
index 84e9cac75dc6..3535417f14ea 100644
--- a/packages/solutions/app-tools/src/builder/generator/createBuilderProviderConfig.ts
+++ b/packages/solutions/app-tools/src/builder/generator/createBuilderProviderConfig.ts
@@ -76,6 +76,13 @@ export function createBuilderProviderConfig(
},
};
+ // Only works with `enableAsyncEntry`:
+ // when both are enabled, `preEntry` should be handled by Modern.js entry code
+ // generation (inject into `index.jsx`), not by builder.
+ if (config.source?.enableAsyncEntry && config.source?.enableAsyncPreEntry) {
+ delete (config.source as any).preEntry;
+ }
+
modifyOutputConfig(config, appContext);
return config as AppNormalizedConfig;
diff --git a/packages/solutions/app-tools/src/config/default.ts b/packages/solutions/app-tools/src/config/default.ts
index 6396cdfd0472..5400648ca03f 100644
--- a/packages/solutions/app-tools/src/config/default.ts
+++ b/packages/solutions/app-tools/src/config/default.ts
@@ -39,6 +39,7 @@ export function createDefaultConfig(
entries: undefined,
mainEntryName: DEFAULT_ENTRY_NAME,
enableAsyncEntry: false,
+ enableAsyncPreEntry: false,
disableDefaultEntries: false,
entriesDir: './src',
configDir: './config',
diff --git a/packages/solutions/app-tools/src/types/config/source.ts b/packages/solutions/app-tools/src/types/config/source.ts
index 9611f201caa7..024917d4ef8b 100644
--- a/packages/solutions/app-tools/src/types/config/source.ts
+++ b/packages/solutions/app-tools/src/types/config/source.ts
@@ -20,6 +20,11 @@ export type Entry =
export type Entries = Record;
export interface SourceUserConfig extends NonNullable {
+ /**
+ * Add code before each page entry. It will be executed before the page code.
+ * @default []
+ */
+ preEntry?: string | string[];
/**
* Used to configure custom page entries.
*/
@@ -35,6 +40,15 @@ export interface SourceUserConfig extends NonNullable {
* @default false
*/
enableAsyncEntry?: boolean;
+ /**
+ * When enabled, framework will inject `source.preEntry` into the top of the
+ * auto-generated entry file (`index.jsx`) and will not pass `source.preEntry`
+ * to builder config.
+ * This is useful when `source.enableAsyncEntry` is enabled and you still want
+ * preEntry to run before the real entry code.
+ * @default false
+ */
+ enableAsyncPreEntry?: boolean;
/**
* Used to disable the functionality of automatically identifying page entry points based on directory structure.
* @default false
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a67dc27d29e1..572f8df88141 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4086,6 +4086,34 @@ importers:
specifier: ^5
version: 5.9.3
+ tests/integration/ssr/fixtures/base-async-pre-entry:
+ dependencies:
+ '@modern-js/runtime':
+ specifier: workspace:*
+ version: link:../../../../../packages/runtime/plugin-runtime
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@modern-js/app-tools':
+ specifier: workspace:*
+ version: link:../../../../../packages/solutions/app-tools
+ '@modern-js/tsconfig':
+ specifier: workspace:*
+ version: link:../../../../../packages/tsconfig
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+
tests/integration/ssr/fixtures/base-json:
dependencies:
'@modern-js/runtime':
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/modern.config.ts b/tests/integration/ssr/fixtures/base-async-pre-entry/modern.config.ts
new file mode 100644
index 000000000000..39cf16e11aed
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/modern.config.ts
@@ -0,0 +1,14 @@
+import { applyBaseConfig } from '../../../../utils/applyBaseConfig';
+
+export default applyBaseConfig({
+ server: {
+ ssr: {
+ mode: 'string',
+ },
+ },
+ source: {
+ enableAsyncEntry: true,
+ enableAsyncPreEntry: true,
+ preEntry: ['./src/pre.ts'],
+ },
+});
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/package.json b/tests/integration/ssr/fixtures/base-async-pre-entry/package.json
new file mode 100644
index 000000000000..982e91ce9e77
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "ssr-base-async-pre-entry-test",
+ "version": "2.66.0",
+ "private": true,
+ "scripts": {
+ "dev": "modern dev"
+ },
+ "dependencies": {
+ "@modern-js/runtime": "workspace:*",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@modern-js/app-tools": "workspace:*",
+ "@modern-js/tsconfig": "workspace:*",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5"
+ }
+}
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/src/modern-app-env.d.ts b/tests/integration/ssr/fixtures/base-async-pre-entry/src/modern-app-env.d.ts
new file mode 100644
index 000000000000..1e851dcf7213
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/src/modern-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/src/pre.ts b/tests/integration/ssr/fixtures/base-async-pre-entry/src/pre.ts
new file mode 100644
index 000000000000..922021d0e990
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/src/pre.ts
@@ -0,0 +1,9 @@
+declare global {
+ interface Window {
+ __pre_entry_flag?: number;
+ }
+}
+
+window.__pre_entry_flag = 1;
+
+export {};
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/index.css b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/index.css
new file mode 100644
index 000000000000..29f09a336b8e
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/index.css
@@ -0,0 +1,3 @@
+body {
+ background-color: #f0f0f0;
+}
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/layout.tsx b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/layout.tsx
new file mode 100644
index 000000000000..9d96453e2416
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/layout.tsx
@@ -0,0 +1,11 @@
+import { Outlet } from '@modern-js/runtime/router';
+import './index.css';
+
+export default function Layout() {
+ return (
+
+ Root layout
+
+
+ );
+}
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/page.tsx b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/page.tsx
new file mode 100644
index 000000000000..6817133e392a
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/src/routes/page.tsx
@@ -0,0 +1,7 @@
+export default function Page() {
+ const flag =
+ typeof window === 'undefined'
+ ? 'ssr'
+ : String((window as any).__pre_entry_flag);
+ return {flag}
;
+}
diff --git a/tests/integration/ssr/fixtures/base-async-pre-entry/tsconfig.json b/tests/integration/ssr/fixtures/base-async-pre-entry/tsconfig.json
new file mode 100644
index 000000000000..418b03f2b0c9
--- /dev/null
+++ b/tests/integration/ssr/fixtures/base-async-pre-entry/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@modern-js/tsconfig/base",
+ "compilerOptions": {
+ "declaration": false,
+ "jsx": "preserve",
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["./src/*"],
+ "@shared/*": ["./shared/*"],
+ "@api/*": ["./api/*"]
+ }
+ },
+ "include": ["src", "shared", "config", "modern.config.ts", "api", "server"]
+}
diff --git a/tests/integration/ssr/tests/base-async-pre-entry.test.ts b/tests/integration/ssr/tests/base-async-pre-entry.test.ts
new file mode 100644
index 000000000000..e8ef76959fa3
--- /dev/null
+++ b/tests/integration/ssr/tests/base-async-pre-entry.test.ts
@@ -0,0 +1,73 @@
+import dns from 'node:dns';
+import path, { join } from 'path';
+import { fs } from '@modern-js/utils';
+import puppeteer, { type Browser, type Page } from 'puppeteer';
+import {
+ getPort,
+ killApp,
+ launchApp,
+ launchOptions,
+} from '../../../utils/modernTestUtils';
+
+const fixtureDir = path.resolve(__dirname, '../fixtures');
+
+dns.setDefaultResultOrder('ipv4first');
+
+describe('enableAsyncPreEntry', () => {
+ let app: any;
+ let appPort: number;
+ let page: Page;
+ let browser: Browser;
+ let appDir: string;
+
+ beforeAll(async () => {
+ appDir = join(fixtureDir, 'base-async-pre-entry');
+ appPort = await getPort();
+ app = await launchApp(appDir, appPort);
+
+ browser = await puppeteer.launch(launchOptions as any);
+ page = await browser.newPage();
+ });
+
+ afterAll(async () => {
+ if (browser) {
+ await browser.close();
+ }
+ if (app) {
+ await killApp(app);
+ }
+ });
+
+ test('should inject preEntry into index.jsx (not bootstrap.jsx)', () => {
+ const internalDir = path.join(appDir, 'node_modules/.modern-js/index');
+ const indexFile = path.join(internalDir, 'index.jsx');
+ const bootstrapFile = path.join(internalDir, 'bootstrap.jsx');
+
+ const indexCode = fs.readFileSync(indexFile, 'utf8');
+ const bootstrapCode = fs.readFileSync(bootstrapFile, 'utf8');
+
+ // preEntry import should be the first statement(s) of index.jsx
+ expect(indexCode.startsWith('import')).toBeTruthy();
+ expect(indexCode).toContain('pre.ts');
+
+ // bootstrap.jsx should remain an async boundary only
+ expect(bootstrapCode).not.toContain('pre.ts');
+ expect(bootstrapCode).toContain(
+ 'import(/* webpackChunkName: "async-index" */ \'./index\');',
+ );
+ });
+
+ test('should execute preEntry in async entry scenario', async () => {
+ await page.goto(`http://localhost:${appPort}`, {
+ waitUntil: 'load',
+ });
+
+ await page.waitForSelector('#pre-entry-flag');
+ await page.waitForFunction(() => {
+ return document.querySelector('#pre-entry-flag')?.textContent === '1';
+ });
+
+ const text = await page.$eval('#pre-entry-flag', el => el.textContent);
+ expect(text).toBe('1');
+ });
+});