From 87ab91a89e1120685eff7cd44acb6afd4bdf8433 Mon Sep 17 00:00:00 2001 From: byte-lyh Date: Wed, 21 Oct 2026 08:26:46 +0000 Subject: [PATCH 1/3] fix(app-tools): scope async HTML scripts to streaming entries --- .changeset/quiet-entries-stream-separately.md | 7 + .../configure/app/server/ssr-by-entries.mdx | 2 + .../configure/app/server/ssr-by-entries.mdx | 2 + .../shared/builderPlugins/adapterSSR.ts | 46 +++- .../bundlerPlugins/HtmlAsyncChunkPlugin.ts | 16 +- .../builder/HtmlAsyncChunkPlugin.test.ts | 76 +++++- .../app-tools/tests/builder/ssrEntry.test.ts | 67 +++++ .../ssr/tests/mixed-script-loading.test.ts | 230 ++++++++++++++++++ 8 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 .changeset/quiet-entries-stream-separately.md create mode 100644 packages/solutions/app-tools/tests/builder/ssrEntry.test.ts create mode 100644 tests/integration/ssr/tests/mixed-script-loading.test.ts diff --git a/.changeset/quiet-entries-stream-separately.md b/.changeset/quiet-entries-stream-separately.md new file mode 100644 index 000000000000..0010c72707b0 --- /dev/null +++ b/.changeset/quiet-entries-stream-separately.md @@ -0,0 +1,7 @@ +--- +'@modern-js/app-tools': patch +--- + +fix(app-tools): scope streaming HTML script rewriting to each entry so CSR and string SSR pages retain their script order and injection settings in mixed applications. + +fix(app-tools): 按入口处理流式 HTML 脚本改写,避免混用应用中的 CSR 和字符串 SSR 页面丢失脚本执行顺序或插入位置配置。 diff --git a/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx b/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx index 221c47546382..a366830618d0 100644 --- a/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx +++ b/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx @@ -27,3 +27,5 @@ export default defineConfig({ ``` In the above configuration, the project enables SSR as a whole, but the SSR rendering ability is disabled for the `page-a` entry. + +When streaming SSR and non-streaming entries share an application, the streaming script layout applies only to the streaming entries. CSR entries and entries with `mode: 'string'` retain their configured script loading and injection behavior. Enabling a streaming entry does not make their `defer` scripts asynchronous. Applications using RSC retain the RSC script layout. diff --git a/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx b/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx index 22ecbd5018f1..5a6152052f4f 100644 --- a/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx +++ b/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx @@ -27,3 +27,5 @@ export default defineConfig({ ``` 如上配置,项目整体启用了 SSR, 但是针对入口 `page-a` 关闭了 SSR 渲染能力。 + +同一应用混用流式 SSR 和非流式入口时,流式脚本布局只作用于流式入口。CSR 入口和 `mode: 'string'` 的入口保留原有的脚本加载方式和插入位置,不会因为另一个入口启用流式 SSR,就将自身的 `defer` 脚本改为 `async`。启用 RSC 的应用仍使用 RSC 的脚本布局。 diff --git a/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts b/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts index 09142bc52a42..35aae8a002ba 100644 --- a/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts +++ b/packages/solutions/app-tools/src/builder/shared/builderPlugins/adapterSSR.ts @@ -4,7 +4,13 @@ import { SERVICE_WORKER_ENVIRONMENT_NAME, isHtmlDisabled, } from '@modern-js/builder'; -import { fs, isUseRsc, isUseSSRBundle, logger } from '@modern-js/utils'; +import { + fs, + getEntryOptions, + isUseRsc, + isUseSSRBundle, + logger, +} from '@modern-js/utils'; import { type RsbuildPlugin, type RspackChain, @@ -17,6 +23,7 @@ import type { ServerUserConfig, } from '../../../types'; import type { AppToolsContext } from '../../../types/plugin'; +import { isMainEntry } from '../../../utils/routes'; import { HtmlAsyncChunkPlugin, RouterPlugin } from '../bundlerPlugins'; import { aggregateEagerRouteComponentFiles, @@ -35,7 +42,10 @@ export const builderPluginAdapterSSR = ( api.modifyRsbuildConfig(config => { const merged = mergeRsbuildConfig(config, { html: { - inject: isStreamingSSR(normalizedConfig) ? 'head' : undefined, + inject: ({ entryName, value }) => + isStreamingSSR(normalizedConfig, entryName, appContext) + ? 'head' + : value, }, server: { // the http-compression can't handler stream http. @@ -104,6 +114,7 @@ export const builderPluginAdapterSSR = ( applyAsyncChunkHtmlPlugin({ chain, modernConfig: options.normalizedConfig, + appContext, HtmlBundlerPlugin, }); } @@ -112,7 +123,11 @@ export const builderPluginAdapterSSR = ( }, }); -const isStreamingSSR = (userConfig: AppNormalizedConfig): boolean => { +export const isStreamingSSR = ( + userConfig: AppNormalizedConfig, + entryName?: string, + appContext?: Pick, +): boolean => { const isStreaming = (ssr: ServerUserConfig['ssr']) => { if (!ssr) { return false; @@ -127,12 +142,24 @@ const isStreamingSSR = (userConfig: AppNormalizedConfig): boolean => { const { server } = userConfig; + if (entryName !== undefined) { + return isStreaming( + getEntryOptions( + entryName, + isMainEntry(entryName, userConfig.source?.mainEntryName), + server.ssr, + server.ssrByEntries, + appContext?.packageName, + ), + ); + } + if (isStreaming(server.ssr)) { return true; } - // Since we cannot apply different plugins for different entries, - // we regard the whole app as streaming ssr only if one entry meets the requirement. + // Application-wide setup still needs the plugin when any entry streams. + // The HTML hooks decide whether to process each individual entry. if (server?.ssrByEntries && typeof server.ssrByEntries === 'object') { for (const name of Object.keys(server.ssrByEntries)) { if (isStreaming(server.ssrByEntries[name])) { @@ -212,16 +239,23 @@ function warnUnresolvedRouteComponents( function applyAsyncChunkHtmlPlugin({ chain, modernConfig, + appContext, HtmlBundlerPlugin, }: { chain: RspackChain; modernConfig: AppNormalizedConfig; + appContext: AppToolsContext; HtmlBundlerPlugin: any; }) { if (isStreamingSSR(modernConfig) || isUseRsc(modernConfig)) { chain .plugin('html-async-chunk') - .use(HtmlAsyncChunkPlugin, [HtmlBundlerPlugin]); + .use(HtmlAsyncChunkPlugin, [ + HtmlBundlerPlugin, + (entryName: string) => + isUseRsc(modernConfig) || + isStreamingSSR(modernConfig, entryName, appContext), + ]); } } diff --git a/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts b/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts index 6ad9da39e57b..029bac885612 100644 --- a/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts +++ b/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts @@ -6,11 +6,21 @@ export class HtmlAsyncChunkPlugin { htmlPlugin: typeof Rspack.HtmlRspackPlugin; - constructor(htmlPlugin: typeof Rspack.HtmlRspackPlugin) { + constructor( + htmlPlugin: typeof Rspack.HtmlRspackPlugin, + private readonly shouldProcessEntry?: (entryName: string) => boolean, + ) { this.name = 'HtmlAsyncChunkPlugin'; this.htmlPlugin = htmlPlugin; } + private shouldProcess(plugin: { options: unknown }) { + if (!this.shouldProcessEntry) return true; + // Rsbuild supplies the owning entry independently of the included chunks. + const { entryName } = plugin.options as { entryName?: string }; + return entryName !== undefined && this.shouldProcessEntry(entryName); + } + apply(compiler: Rspack.Compiler) { compiler.hooks.compilation.tap(this.name, compilation => { const hooks = this.htmlPlugin.getCompilationHooks(compilation as any); @@ -35,6 +45,8 @@ export class HtmlAsyncChunkPlugin { // scripts and removes their src in production. Move runtime to body while // src still identifies it so entry execution waits for initial SSR data. hooks.alterAssetTagGroups.tap({ name: this.name, stage: -1 }, assets => { + if (!this.shouldProcess(assets.plugin)) return assets; + const headTags: typeof assets.headTags = []; const bodyTags: typeof assets.bodyTags = []; const entryRuntimeUrls = runtimeInEntry @@ -81,6 +93,8 @@ export class HtmlAsyncChunkPlugin { // Use final attributes after Rsbuild's HTML processing so preload CORS // and integrity settings match the script. Inlined entries need no hint. hooks.alterAssetTagGroups.tap({ name: this.name, stage: 1 }, assets => { + if (!this.shouldProcess(assets.plugin)) return assets; + const entryRuntimeUrls = getEntryRuntimeUrls(assets.publicPath); for (const tag of assets.bodyTags) { const src = tag.attributes?.src; diff --git a/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts b/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts index f586fde6f4ca..c3d02736d7b4 100644 --- a/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts +++ b/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts @@ -8,13 +8,19 @@ type Tag = { innerHTML?: string; }; -type Assets = { headTags: Tag[]; bodyTags: Tag[]; publicPath: string }; +type Assets = { + headTags: Tag[]; + bodyTags: Tag[]; + publicPath: string; + plugin?: { options: { entryName?: string; chunks?: string[] } }; +}; type HtmlCallback = (assets: Assets) => Assets; function runHtmlHooks( assets: Assets, entryFiles: string[], processHtml: HtmlCallback = value => value, + shouldProcessEntry?: (entryName: string) => boolean, ) { const taps: { stage: number; callback: HtmlCallback }[] = [ { stage: 0, callback: processHtml }, @@ -47,6 +53,7 @@ function runHtmlHooks( }; new HtmlAsyncChunkPlugin( htmlPlugin as unknown as typeof Rspack.HtmlRspackPlugin, + shouldProcessEntry, ).apply(compiler as unknown as Rspack.Compiler); return taps .sort((a, b) => a.stage - b.stage) @@ -198,3 +205,70 @@ describe('HtmlAsyncChunkPlugin entry preloads', () => { }, ); }); + +describe('HtmlAsyncChunkPlugin entry isolation', () => { + it.each(['csr', 'string'])( + 'preserves %s scripts and skips entry preloads', + entryName => { + const assets: Assets = { + publicPath: '/', + headTags: [script('/vendor.js')], + bodyTags: [script(`/${entryName}.js`)], + // Included dependency chunks must not determine the page rendering mode. + plugin: { options: { entryName, chunks: ['stream', entryName] } }, + }; + const result = runHtmlHooks( + assets, + [`${entryName}.js`], + value => value, + name => name === 'stream', + ); + expect(result).toBe(assets); + expect(result.headTags).toEqual([script('/vendor.js')]); + expect(result.bodyTags).toEqual([script(`/${entryName}.js`)]); + }, + ); + + it('still rewrites and preloads the streaming entry', () => { + const result = runHtmlHooks( + { + publicPath: '/', + headTags: [script('/stream.js')], + bodyTags: [script('/vendor.js')], + plugin: { options: { entryName: 'stream' } }, + }, + ['stream.js'], + value => value, + name => name === 'stream', + ); + expect(result.bodyTags).toEqual([ + { + tagName: 'script', + voidTag: false, + attributes: { src: '/stream.js', async: true }, + }, + ]); + expect(result.headTags.map(tag => tag.attributes)).toEqual([ + { src: '/vendor.js', async: true }, + { rel: 'preload', as: 'script', href: '/stream.js' }, + ]); + }); + + it('does not rewrite HTML without an owning entry', () => { + const assets: Assets = { + publicPath: '/', + headTags: [script('/custom.js')], + bodyTags: [], + plugin: { options: {} }, + }; + expect( + runHtmlHooks( + assets, + ['custom.js'], + value => value, + () => true, + ), + ).toBe(assets); + expect(assets.headTags).toEqual([script('/custom.js')]); + }); +}); diff --git a/packages/solutions/app-tools/tests/builder/ssrEntry.test.ts b/packages/solutions/app-tools/tests/builder/ssrEntry.test.ts new file mode 100644 index 000000000000..2405f07b4cb3 --- /dev/null +++ b/packages/solutions/app-tools/tests/builder/ssrEntry.test.ts @@ -0,0 +1,67 @@ +import { isStreamingSSR } from '../../src/builder/shared/builderPlugins/adapterSSR'; +import type { AppNormalizedConfig, ServerUserConfig } from '../../src/types'; + +function config(server: ServerUserConfig): AppNormalizedConfig { + return { server, source: { mainEntryName: 'index' } } as AppNormalizedConfig; +} + +describe('streaming SSR entry selection', () => { + it.each([true, {}, { mode: 'stream' }] as const)( + 'inherits streaming SSR from %j while honoring explicit entry overrides', + ssr => { + const value = config({ + ssr, + ssrByEntries: { csr: false, string: { mode: 'string' } }, + }); + expect(isStreamingSSR(value)).toBe(true); + expect(isStreamingSSR(value, 'stream')).toBe(true); + expect(isStreamingSSR(value, 'csr')).toBe(false); + expect(isStreamingSSR(value, 'string')).toBe(false); + }, + ); + + it('enables only the streaming entries when global SSR is disabled', () => { + const value = config({ + ssr: false, + ssrByEntries: { + stream: true, + defaultMode: {}, + string: { mode: 'string' }, + }, + }); + expect(isStreamingSSR(value)).toBe(true); + expect(isStreamingSSR(value, 'stream')).toBe(true); + expect(isStreamingSSR(value, 'defaultMode')).toBe(true); + expect(isStreamingSSR(value, 'string')).toBe(false); + expect(isStreamingSSR(value, 'csr')).toBe(false); + }); + + it('merges object overrides with the global string SSR mode', () => { + const value = config({ + ssr: { mode: 'string' }, + ssrByEntries: { inherited: {}, stream: { mode: 'stream' } }, + }); + expect(isStreamingSSR(value, 'inherited')).toBe(false); + expect(isStreamingSSR(value, 'stream')).toBe(true); + }); + + it('resolves the legacy package-name override only for the main entry', () => { + const value = config({ ssr: true, ssrByEntries: { 'test-app': false } }); + expect(isStreamingSSR(value, 'index', { packageName: 'test-app' })).toBe( + false, + ); + expect(isStreamingSSR(value, 'other', { packageName: 'test-app' })).toBe( + true, + ); + }); + + it('prefers the explicit main-entry override to the package-name alias', () => { + const value = config({ + ssr: false, + ssrByEntries: { index: { mode: 'string' }, 'test-app': true }, + }); + expect(isStreamingSSR(value, 'index', { packageName: 'test-app' })).toBe( + false, + ); + }); +}); diff --git a/tests/integration/ssr/tests/mixed-script-loading.test.ts b/tests/integration/ssr/tests/mixed-script-loading.test.ts new file mode 100644 index 000000000000..146eddf4af7b --- /dev/null +++ b/tests/integration/ssr/tests/mixed-script-loading.test.ts @@ -0,0 +1,230 @@ +import type { ChildProcess } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import puppeteer, { type Browser } from 'puppeteer'; +import { + createIsolatedTestApp, + getPort, + killApp, + launchOptions, + modernBuild, + modernServe, +} from '../../../utils/modernTestUtils'; + +type OrderState = { + sdkReady?: boolean; + business?: { sdkReady: boolean }; + dcl: boolean; + businessDcl: number; +}; +type OrderWindow = Window & { __scriptOrder: OrderState }; + +// Cover both HTML implementations and runtime embedded in the entry itself. +describe.each([ + ['native', true], + ['native', false], + ['js', true], + ['js', false], +] as const)( + 'mixed SSR scripts (%s, separate runtime: %s)', + (implementation, separateRuntime) => { + let fixture: Awaited>; + let app: ChildProcess | undefined; + let browser: Browser | undefined; + let origin: string; + + beforeAll(async () => { + fixture = await createIsolatedTestApp( + path.resolve(__dirname, '../fixtures/streaming'), + { exclude: ['src'] }, + ); + await writeFile( + path.join(fixture.appDir, 'modern.config.ts'), + ` +import { applyBaseConfig } from '../../../../utils/applyBaseConfig'; +export default applyBaseConfig({ + source: { disableDefaultEntries: true, preEntry: ['./src/probe.ts'], entries: { csr: './src/csr/routes', string: './src/string/routes', stream: './src/stream/routes' } }, + server: { ssr: true, ssrByEntries: { csr: false, string: { mode: 'string' } } }, + html: { inject: 'body', template: './template.html' }, + builderPlugins: [{ name: 'test-html-implementation', setup(api) { api.modifyRsbuildConfig(config => { config.html = { ...config.html, implementation: '${implementation}' }; }); } }], + tools: { + // Native HTML accepts a boolean, not Modern's JS minifier callback. + ...(${implementation === 'native'} ? { htmlPlugin: { minify: false } } : {}), + rspack(config, { isServer }) { if (!isServer && !${separateRuntime}) config.optimization.runtimeChunk = false; }, + }, +});`, + ); + await writeFile( + path.join(fixture.appDir, 'template.html'), + '
', + ); + for (const entry of ['csr', 'string', 'stream']) { + const dir = path.join(fixture.appDir, 'src', entry, 'routes'); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, 'layout.tsx'), + "import { Outlet } from '@modern-js/runtime/router'; export default function Layout() { return ; }", + ); + await writeFile( + path.join(dir, 'page.tsx'), + ` +import { useState } from 'react'; + +export default function App() { + const [count, setCount] = useState(0); + return ; +}`, + ); + } + await writeFile( + path.join(fixture.appDir, 'src/probe.ts'), + `if (typeof window !== 'undefined') { + const state = (window as Window & { __scriptOrder: { sdkReady?: boolean; business?: { sdkReady: boolean }; businessDcl: number } }).__scriptOrder; + if (state) { + state.business = { sdkReady: state.sdkReady === true }; + document.addEventListener('DOMContentLoaded', () => { state.businessDcl++; }); + } +}`, + ); + const result = await modernBuild(fixture.appDir); + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0); + const port = await getPort(); + origin = `http://127.0.0.1:${port}`; + app = await modernServe(fixture.appDir, port); + browser = await puppeteer.launch({ ...launchOptions, headless: true }); + }, 120000); + + afterAll(async () => { + try { + await browser?.close(); + } finally { + try { + if (app) await killApp(app); + } finally { + await fixture?.cleanup(); + } + } + }); + + it.each(['csr', 'string', 'stream'])( + 'keeps the %s entry script in its own rendering layout', + async entry => { + const response = await fetch(`${origin}/${entry}`, { + headers: { 'user-agent': 'Mozilla/5.0' }, + }); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html.includes('id="counter"')).toBe(entry !== 'csr'); + const scripts = [ + ...html.matchAll(/]*\ssrc="([^"]+)"[^>]*>/g), + ].filter(match => match[1].startsWith('/static/')); + expect(scripts.length).toBeGreaterThan(0); + for (const script of scripts) { + const isRuntime = + /\/builder-runtime(?:\.|\.js)/.test(script[1]) || + (!separateRuntime && /\/stream\./.test(script[1])); + if (entry === 'stream') { + expect(script[0]).toMatch(/\sasync(?:[\s=>])/); + expect(script[0]).not.toMatch(/\sdefer(?:[\s=>])/); + if (!isRuntime) + expect(script.index).toBeLessThan(html.indexOf('')); + } else { + expect(script[0]).toMatch(/\sdefer(?:[\s=>])/); + expect(script[0]).not.toMatch(/\sasync(?:[\s=>])/); + expect(script.index).toBeGreaterThan(html.indexOf('')); + } + } + if (!separateRuntime && entry !== 'stream') { + expect(html).not.toMatch( + /]+rel="(?:module)?preload"[^>]+(?:csr|string)\./, + ); + } + }, + ); + + it.each([ + ['csr', 'entry'], + ['csr', 'sdk'], + ['string', 'entry'], + ['string', 'sdk'], + ] as const)( + 'preserves SDK order and DOMContentLoaded for %s with a held %s script', + async (entry, heldScript) => { + const page = await browser!.newPage(); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + let release: () => void = () => {}; + const gate = new Promise(resolve => { + release = resolve; + }); + let notifyHeld: () => void = () => {}; + const held = new Promise(resolve => { + notifyHeld = resolve; + }); + let navigation: Promise | undefined; + try { + await page.evaluateOnNewDocument(() => { + const state: OrderState = { dcl: false, businessDcl: 0 }; + (window as OrderWindow).__scriptOrder = state; + document.addEventListener('DOMContentLoaded', () => { + state.dcl = true; + }); + }); + await page.setRequestInterception(true); + page.on('request', async request => { + const pathname = new URL(request.url()).pathname; + const isSdk = pathname === '/order-sdk.js'; + const isHeld = + heldScript === 'sdk' + ? isSdk + : pathname.startsWith(`/static/js/${entry}.`); + if (isHeld) { + notifyHeld(); + await gate; + } + if (isSdk) { + await request.respond({ + contentType: 'application/javascript', + body: 'window.__scriptOrder.sdkReady = true;', + }); + } else { + await request.continue(); + } + }); + navigation = page.goto(`${origin}/${entry}`, { waitUntil: 'load' }); + await Promise.race([ + held, + navigation.then(() => { + throw new Error('Expected to hold a script request'); + }), + ]); + // Finish every other request while the selected script is still held. + await page.waitForNetworkIdle({ concurrency: 1, idleTime: 200 }); + const before = await page.evaluate( + () => (window as OrderWindow).__scriptOrder, + ); + expect(before.business).toBeUndefined(); + expect(before.dcl).toBe(false); + release(); + await navigation; + await page.waitForSelector('#counter'); + await page.click('#counter'); + await page.waitForFunction( + () => document.querySelector('#counter')?.textContent === 'count:1', + ); + const after = await page.evaluate( + () => (window as OrderWindow).__scriptOrder, + ); + expect(after.business).toEqual({ sdkReady: true }); + expect(after.dcl).toBe(true); + expect(after.businessDcl).toBe(1); + expect(errors).toEqual([]); + } finally { + release(); + await navigation?.catch(() => {}); + await page.close(); + } + }, + ); + }, +); From feac6fa0c37ef017d7c246a2a913d5b036eabcf2 Mon Sep 17 00:00:00 2001 From: byte-lyh Date: Wed, 21 Oct 2026 08:29:34 +0000 Subject: [PATCH 2/3] docs: remove redundant entry isolation clarification --- .../document/docs/en/configure/app/server/ssr-by-entries.mdx | 2 -- .../document/docs/zh/configure/app/server/ssr-by-entries.mdx | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx b/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx index a366830618d0..221c47546382 100644 --- a/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx +++ b/packages/document/docs/en/configure/app/server/ssr-by-entries.mdx @@ -27,5 +27,3 @@ export default defineConfig({ ``` In the above configuration, the project enables SSR as a whole, but the SSR rendering ability is disabled for the `page-a` entry. - -When streaming SSR and non-streaming entries share an application, the streaming script layout applies only to the streaming entries. CSR entries and entries with `mode: 'string'` retain their configured script loading and injection behavior. Enabling a streaming entry does not make their `defer` scripts asynchronous. Applications using RSC retain the RSC script layout. diff --git a/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx b/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx index 5a6152052f4f..22ecbd5018f1 100644 --- a/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx +++ b/packages/document/docs/zh/configure/app/server/ssr-by-entries.mdx @@ -27,5 +27,3 @@ export default defineConfig({ ``` 如上配置,项目整体启用了 SSR, 但是针对入口 `page-a` 关闭了 SSR 渲染能力。 - -同一应用混用流式 SSR 和非流式入口时,流式脚本布局只作用于流式入口。CSR 入口和 `mode: 'string'` 的入口保留原有的脚本加载方式和插入位置,不会因为另一个入口启用流式 SSR,就将自身的 `defer` 脚本改为 `async`。启用 RSC 的应用仍使用 RSC 的脚本布局。 From c44346ed174cf82874a5a6cbf6424ea7de3da717 Mon Sep 17 00:00:00 2001 From: byte-lyh Date: Wed, 21 Oct 2026 08:36:52 +0000 Subject: [PATCH 3/3] fix(app-tools): warn when HTML entry metadata is missing --- .../bundlerPlugins/HtmlAsyncChunkPlugin.ts | 14 +++++- .../builder/HtmlAsyncChunkPlugin.test.ts | 31 ++++++++----- .../ssr/tests/mixed-script-loading.test.ts | 43 ++++++++++++++++--- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts b/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts index 029bac885612..031e60aa7c6e 100644 --- a/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts +++ b/packages/solutions/app-tools/src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin.ts @@ -1,11 +1,14 @@ import type { Rspack } from '@modern-js/builder'; import { RUNTIME_CHUNK_REGEX } from '@modern-js/builder'; +import { logger } from '@modern-js/utils'; export class HtmlAsyncChunkPlugin { name: string; htmlPlugin: typeof Rspack.HtmlRspackPlugin; + private warnedMissingEntryName = false; + constructor( htmlPlugin: typeof Rspack.HtmlRspackPlugin, private readonly shouldProcessEntry?: (entryName: string) => boolean, @@ -18,7 +21,16 @@ export class HtmlAsyncChunkPlugin { if (!this.shouldProcessEntry) return true; // Rsbuild supplies the owning entry independently of the included chunks. const { entryName } = plugin.options as { entryName?: string }; - return entryName !== undefined && this.shouldProcessEntry(entryName); + if (entryName === undefined) { + if (!this.warnedMissingEntryName) { + this.warnedMissingEntryName = true; + logger.warn( + '[HtmlAsyncChunkPlugin] Skipping streaming script processing because HTML options are missing entryName. When returning a new object from tools.htmlPlugin, spread the original options to preserve entryName.', + ); + } + return false; + } + return this.shouldProcessEntry(entryName); } apply(compiler: Rspack.Compiler) { diff --git a/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts b/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts index c3d02736d7b4..a166a496087f 100644 --- a/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts +++ b/packages/solutions/app-tools/tests/builder/HtmlAsyncChunkPlugin.test.ts @@ -1,4 +1,6 @@ import type { Rspack } from '@modern-js/builder'; +import { logger } from '@modern-js/utils'; +import { rs } from '@rstest/core'; import { HtmlAsyncChunkPlugin } from '../../src/builder/shared/bundlerPlugins/HtmlAsyncChunkPlugin'; type Tag = { @@ -254,21 +256,30 @@ describe('HtmlAsyncChunkPlugin entry isolation', () => { ]); }); - it('does not rewrite HTML without an owning entry', () => { + it('warns once across HTML hooks and preserves scripts without an owning entry', () => { + const warn = rs.spyOn(logger, 'warn').mockImplementation(() => {}); const assets: Assets = { publicPath: '/', headTags: [script('/custom.js')], bodyTags: [], plugin: { options: {} }, }; - expect( - runHtmlHooks( - assets, - ['custom.js'], - value => value, - () => true, - ), - ).toBe(assets); - expect(assets.headTags).toEqual([script('/custom.js')]); + try { + expect( + runHtmlHooks( + assets, + ['custom.js'], + value => value, + () => true, + ), + ).toBe(assets); + expect(assets.headTags).toEqual([script('/custom.js')]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/missing entryName.*tools\.htmlPlugin/), + ); + } finally { + warn.mockRestore(); + } }); }); diff --git a/tests/integration/ssr/tests/mixed-script-loading.test.ts b/tests/integration/ssr/tests/mixed-script-loading.test.ts index 146eddf4af7b..a44bc5a177c0 100644 --- a/tests/integration/ssr/tests/mixed-script-loading.test.ts +++ b/tests/integration/ssr/tests/mixed-script-loading.test.ts @@ -43,14 +43,47 @@ describe.each([ ` import { applyBaseConfig } from '../../../../utils/applyBaseConfig'; export default applyBaseConfig({ - source: { disableDefaultEntries: true, preEntry: ['./src/probe.ts'], entries: { csr: './src/csr/routes', string: './src/string/routes', stream: './src/stream/routes' } }, - server: { ssr: true, ssrByEntries: { csr: false, string: { mode: 'string' } } }, - html: { inject: 'body', template: './template.html' }, - builderPlugins: [{ name: 'test-html-implementation', setup(api) { api.modifyRsbuildConfig(config => { config.html = { ...config.html, implementation: '${implementation}' }; }); } }], + source: { + disableDefaultEntries: true, + preEntry: ['./src/probe.ts'], + entries: { + csr: './src/csr/routes', + string: './src/string/routes', + stream: './src/stream/routes', + }, + }, + server: { + ssr: true, + ssrByEntries: { + csr: false, + string: { mode: 'string' }, + }, + }, + html: { + inject: 'body', + template: './template.html', + }, + builderPlugins: [ + { + name: 'test-html-implementation', + setup(api) { + api.modifyRsbuildConfig(config => { + config.html = { + ...config.html, + implementation: '${implementation}', + }; + }); + }, + }, + ], tools: { // Native HTML accepts a boolean, not Modern's JS minifier callback. ...(${implementation === 'native'} ? { htmlPlugin: { minify: false } } : {}), - rspack(config, { isServer }) { if (!isServer && !${separateRuntime}) config.optimization.runtimeChunk = false; }, + rspack(config, { isServer }) { + if (!isServer && !${separateRuntime}) { + config.optimization.runtimeChunk = false; + } + }, }, });`, );