Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/quiet-entries-stream-separately.md
Original file line number Diff line number Diff line change
@@ -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 页面丢失脚本执行顺序或插入位置配置。
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -104,6 +114,7 @@ export const builderPluginAdapterSSR = (
applyAsyncChunkHtmlPlugin({
chain,
modernConfig: options.normalizedConfig,
appContext,
HtmlBundlerPlugin,
});
}
Expand All @@ -112,7 +123,11 @@ export const builderPluginAdapterSSR = (
},
});

const isStreamingSSR = (userConfig: AppNormalizedConfig): boolean => {
export const isStreamingSSR = (
userConfig: AppNormalizedConfig,
entryName?: string,
appContext?: Pick<AppToolsContext, 'packageName'>,
): boolean => {
const isStreaming = (ssr: ServerUserConfig['ssr']) => {
if (!ssr) {
return false;
Expand All @@ -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])) {
Expand Down Expand Up @@ -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),
]);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
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;

constructor(htmlPlugin: typeof Rspack.HtmlRspackPlugin) {
private warnedMissingEntryName = false;

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 };
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) {
compiler.hooks.compilation.tap(this.name, compilation => {
const hooks = this.htmlPlugin.getCompilationHooks(compilation as any);
Expand All @@ -35,6 +57,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
Expand Down Expand Up @@ -81,6 +105,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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -8,13 +10,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 },
Expand Down Expand Up @@ -47,6 +55,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)
Expand Down Expand Up @@ -198,3 +207,79 @@ 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('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: {} },
};
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();
}
});
});
67 changes: 67 additions & 0 deletions packages/solutions/app-tools/tests/builder/ssrEntry.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading
Loading