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'); + }); +});