Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/friendly-planes-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modern-js/app-tools': minor
'@modern-js/runtime': minor
---

feat: add `source.enableAsyncPreEntry` for async entry scenarios
Original file line number Diff line number Diff line change
@@ -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'],
},
});
```
Original file line number Diff line number Diff line change
@@ -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 中。
67 changes: 65 additions & 2 deletions packages/runtime/plugin-runtime/src/cli/code.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/// <reference types="node" />

import path from 'path';
import type {
AppToolsContext,
AppToolsFeatureHooks,
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,
Expand All @@ -20,15 +22,54 @@ 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,
config: AppToolsNormalizedConfig,
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,
Expand Down Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/solutions/app-tools/src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createDefaultConfig(
entries: undefined,
mainEntryName: DEFAULT_ENTRY_NAME,
enableAsyncEntry: false,
enableAsyncPreEntry: false,
disableDefaultEntries: false,
entriesDir: './src',
configDir: './config',
Expand Down
14 changes: 14 additions & 0 deletions packages/solutions/app-tools/src/types/config/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type Entry =
export type Entries = Record<string, Entry>;

export interface SourceUserConfig extends NonNullable<BuilderConfig['source']> {
/**
* Add code before each page entry. It will be executed before the page code.
* @default []
*/
preEntry?: string | string[];
/**
* Used to configure custom page entries.
*/
Expand All @@ -35,6 +40,15 @@ export interface SourceUserConfig extends NonNullable<BuilderConfig['source']> {
* @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
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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'],
},
});
20 changes: 20 additions & 0 deletions tests/integration/ssr/fixtures/base-async-pre-entry/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types='@modern-js/app-tools/types' />
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare global {
interface Window {
__pre_entry_flag?: number;
}
}

window.__pre_entry_flag = 1;

export {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
background-color: #f0f0f0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Outlet } from '@modern-js/runtime/router';
import './index.css';

export default function Layout() {
return (
<div>
Root layout
<Outlet />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
const flag =
typeof window === 'undefined'
? 'ssr'
: String((window as any).__pre_entry_flag);
return <div id="pre-entry-flag">{flag}</div>;
}
14 changes: 14 additions & 0 deletions tests/integration/ssr/fixtures/base-async-pre-entry/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading