From ec3d43b6bdb0414aec1075ee3dd196f87b86d005 Mon Sep 17 00:00:00 2001 From: neverland Date: Wed, 22 Jul 2026 14:20:14 +0800 Subject: [PATCH] fix(core): preserve options when restarting --- .../dev-server-restart/index.test.ts | 1 + .../restart-option/index.test.ts | 1 + .../restart-preserve-options/index.test.ts | 110 ++++++++++++++++++ .../restart-preserve-options/src/index.js | 1 + .../restart-watch-files/index.test.ts | 1 + packages/core/src/build.ts | 8 +- packages/core/src/cli/init.ts | 10 +- packages/core/src/createRsbuild.ts | 22 +++- packages/core/src/restart.ts | 24 ++-- packages/core/src/server/devServer.ts | 13 ++- packages/core/src/types/hooks.ts | 29 ++++- packages/core/tests/restartHook.test.ts | 4 +- website/docs/en/api/javascript-api/core.mdx | 10 +- website/docs/en/shared/onRestart.mdx | 13 ++- website/docs/zh/api/javascript-api/core.mdx | 10 +- website/docs/zh/shared/onRestart.mdx | 13 ++- 16 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 e2e/cases/javascript-api/restart-preserve-options/index.test.ts create mode 100644 e2e/cases/javascript-api/restart-preserve-options/src/index.js diff --git a/e2e/cases/javascript-api/dev-server-restart/index.test.ts b/e2e/cases/javascript-api/dev-server-restart/index.test.ts index c257abc634..ffe9c87795 100644 --- a/e2e/cases/javascript-api/dev-server-restart/index.test.ts +++ b/e2e/cases/javascript-api/dev-server-restart/index.test.ts @@ -47,6 +47,7 @@ test('should watch loaded config dependencies for restart', async () => { .toEqual({ action: 'dev', filePath: configDep, + options: {}, }); } finally { await server.close(); diff --git a/e2e/cases/javascript-api/restart-option/index.test.ts b/e2e/cases/javascript-api/restart-option/index.test.ts index 1b7d4c5611..c8f6872c4c 100644 --- a/e2e/cases/javascript-api/restart-option/index.test.ts +++ b/e2e/cases/javascript-api/restart-option/index.test.ts @@ -68,6 +68,7 @@ test('createRsbuild should support a restart function', async () => { context: { action: 'dev', filePath: watchedFile, + options: {}, }, calls: ['onRestart', 'onCloseDevServer'], }); diff --git a/e2e/cases/javascript-api/restart-preserve-options/index.test.ts b/e2e/cases/javascript-api/restart-preserve-options/index.test.ts new file mode 100644 index 0000000000..b9bbea1edf --- /dev/null +++ b/e2e/cases/javascript-api/restart-preserve-options/index.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@e2e/helper'; +import { createRsbuild, type RestartContext } from '@rsbuild/core'; +import { getRandomPort } from '@rstackjs/test-utils'; + +const watchedFile = path.join(import.meta.dirname, 'test-temp-watch.txt'); + +test.beforeEach(() => { + fs.writeFileSync(watchedFile, '1'); +}); + +test.afterAll(() => { + fs.rmSync(watchedFile, { force: true }); +}); + +test('should preserve startDevServer options after restart', async () => { + let restartContext: RestartContext | undefined; + const rsbuild = await createRsbuild({ + cwd: import.meta.dirname, + config: { + dev: { + watchFiles: { + paths: watchedFile, + type: 'restart', + }, + }, + server: { + port: await getRandomPort(), + }, + }, + restart: (context) => { + restartContext = context; + return true; + }, + }); + + const { server } = await rsbuild.startDevServer({ getPortSilently: true }); + let version = 1; + + try { + await expect + .poll( + () => { + if (!restartContext) { + fs.writeFileSync(watchedFile, String(++version)); + } + return restartContext; + }, + { timeout: 5_000 }, + ) + .toEqual({ + action: 'dev', + filePath: watchedFile, + options: { + getPortSilently: true, + }, + }); + } finally { + await server.close(); + } +}); + +test('should preserve build options after restart', async () => { + let restartContext: RestartContext | undefined; + const rsbuild = await createRsbuild({ + cwd: import.meta.dirname, + config: { + dev: { + watchFiles: { + paths: watchedFile, + type: 'restart', + }, + }, + }, + restart: (context) => { + restartContext = context; + return true; + }, + }); + + const buildReady = new Promise((resolve) => { + rsbuild.onAfterBuild(() => resolve()); + }); + const result = await rsbuild.build({ watch: true }); + await buildReady; + let version = 1; + + try { + await expect + .poll( + () => { + if (!restartContext) { + fs.writeFileSync(watchedFile, String(++version)); + } + return restartContext; + }, + { timeout: 5_000 }, + ) + .toEqual({ + action: 'build', + filePath: watchedFile, + options: { + watch: true, + }, + }); + } finally { + await result.close(); + } +}); diff --git a/e2e/cases/javascript-api/restart-preserve-options/src/index.js b/e2e/cases/javascript-api/restart-preserve-options/src/index.js new file mode 100644 index 0000000000..443accf213 --- /dev/null +++ b/e2e/cases/javascript-api/restart-preserve-options/src/index.js @@ -0,0 +1 @@ +console.log('restart preserve options'); diff --git a/e2e/cases/javascript-api/restart-watch-files/index.test.ts b/e2e/cases/javascript-api/restart-watch-files/index.test.ts index 57c1718c00..17d03dabbd 100644 --- a/e2e/cases/javascript-api/restart-watch-files/index.test.ts +++ b/e2e/cases/javascript-api/restart-watch-files/index.test.ts @@ -41,6 +41,7 @@ const expectRestart = async (rsbuild: RsbuildInstance, action: RestartContext['a .toEqual({ action, filePath: watchedFile, + options: action === 'build' ? { watch: true } : {}, }); }; diff --git a/packages/core/src/build.ts b/packages/core/src/build.ts index b9fe225115..1235889bfa 100644 --- a/packages/core/src/build.ts +++ b/packages/core/src/build.ts @@ -9,8 +9,9 @@ export const RSPACK_BUILD_ERROR = 'Rspack build failed.'; export const build = async ( initOptions: InitConfigsOptions, - { watch }: BuildOptions = {}, + buildOptions: BuildOptions = {}, ): Promise> => { + const { watch } = buildOptions; const { context } = initOptions; const { logger } = context; const { compiler, rspackConfigs } = await createCompiler(initOptions); @@ -84,7 +85,10 @@ export const build = async ( restartWatcher = watchFilesForRestart({ watchFiles: context.normalizedConfig!.dev.watchFiles, context, - action: 'build', + restartContext: { + action: 'build', + options: buildOptions, + }, }); } diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts index 80ba54afb3..565de18cc4 100644 --- a/packages/core/src/cli/init.ts +++ b/packages/core/src/cli/init.ts @@ -3,7 +3,7 @@ import { createRsbuild } from '../createRsbuild'; import { ensureAbsolutePath } from '../helpers/path'; import { loadConfig as baseLoadConfig } from '../loadConfig'; import { defaultLogger } from '../logger'; -import type { RestartContext, RsbuildInstance } from '../types'; +import type { RestartFn, RsbuildInstance } from '../types'; import type { CommonOptions } from './commands'; export type CommandName = 'dev' | 'build' | 'preview' | 'inspect'; @@ -113,7 +113,7 @@ const loadConfig = async (root: string) => { return result; }; -const restart = async ({ action }: RestartContext): Promise => { +const restart: RestartFn = async (context) => { const rsbuild = await init({ isRestart: true }); // Skip restarting if the config cannot be loaded, for example while the @@ -122,10 +122,10 @@ const restart = async ({ action }: RestartContext): Promise => { return false; } - if (action === 'build') { - await rsbuild.build({ watch: true }); + if (context.action === 'build') { + await rsbuild.build(context.options); } else { - await rsbuild.startDevServer(); + await rsbuild.startDevServer(context.options); } return true; }; diff --git a/packages/core/src/createRsbuild.ts b/packages/core/src/createRsbuild.ts index 8e860a5983..9d9e90b30d 100644 --- a/packages/core/src/createRsbuild.ts +++ b/packages/core/src/createRsbuild.ts @@ -63,7 +63,6 @@ import type { RsbuildPlugin, RsbuildPlugins, StartDevServer, - StartDevServerOptions, } from './types'; function applyDefaultPlugins(pluginManager: PluginManager, context: InternalContext) { @@ -253,17 +252,24 @@ export async function createRsbuild(options: CreateRsbuildOptions = {}): Promise return startPreviewServer(context, config, options); }; - const build: Build = async (options) => { + const build: Build = async (options = {}) => { context.action = 'build'; if (!getNodeEnv()) { setNodeEnv('production'); } - return baseBuild({ context, pluginManager, rsbuildOptions: resolvedOptions }, options); + return baseBuild( + { + context, + pluginManager, + rsbuildOptions: resolvedOptions, + }, + { ...options }, + ); }; - const startDevServer: StartDevServer = async (options?: StartDevServerOptions) => { + const startDevServer: StartDevServer = async (options = {}) => { context.action = 'dev'; if (!getNodeEnv()) { @@ -272,10 +278,14 @@ export async function createRsbuild(options: CreateRsbuildOptions = {}): Promise const config = await initRsbuildConfig({ context, pluginManager }); const server = await baseCreateDevServer( - { context, pluginManager, rsbuildOptions: resolvedOptions }, + { + context, + pluginManager, + rsbuildOptions: resolvedOptions, + }, createCompiler, config, - options, + { ...options }, ); return server.listen(); diff --git a/packages/core/src/restart.ts b/packages/core/src/restart.ts index 4ea2a7b0fe..9713769d1f 100644 --- a/packages/core/src/restart.ts +++ b/packages/core/src/restart.ts @@ -13,16 +13,18 @@ const clearConsole = () => { }; export const requestRestart = ({ - filePath, clear = true, - action, logger, + restartContext, restartManager, -}: RestartContext & { +}: { clear?: boolean; logger: Logger; + restartContext: RestartContext; restartManager: RestartManager; }): Promise => { + const { action, filePath } = restartContext; + if (restartManager.canRestart) { if (clear) { clearConsole(); @@ -38,17 +40,17 @@ export const requestRestart = ({ } } - return restartManager.requestRestart({ action, filePath }); + return restartManager.requestRestart(restartContext); }; export function watchFilesForRestart({ watchFiles, context, - action, + restartContext, }: { watchFiles: WatchFiles[]; context: InternalContext; - action: RestartContext['action']; + restartContext: RestartContext; }): WatchFilesResult | undefined { const defaultFiles = context.configFile ? [context.configFile, ...context.configFileDependencies] @@ -99,8 +101,10 @@ export function watchFilesForRestart({ try { const restarted = await requestRestart({ - action, - filePath: absoluteFilePath, + restartContext: { + ...restartContext, + filePath: absoluteFilePath, + }, logger, restartManager, }); @@ -109,7 +113,9 @@ export function watchFilesForRestart({ if (restarted) { await close(); } else if (restartManager.canRestart) { - logger.error(action === 'build' ? 'Restart build failed.' : 'Restart server failed.'); + logger.error( + restartContext.action === 'build' ? 'Restart build failed.' : 'Restart server failed.', + ); } } catch (error) { logger.error(error); diff --git a/packages/core/src/server/devServer.ts b/packages/core/src/server/devServer.ts index c42adf242b..1fbd1759ab 100644 --- a/packages/core/src/server/devServer.ts +++ b/packages/core/src/server/devServer.ts @@ -1,6 +1,6 @@ import type { Server } from 'node:http'; import type { Http2SecureServer } from 'node:http2'; -import { color } from '../helpers'; +import { color, pick } from '../helpers'; import { getPublicPathFromCompiler, isMultiCompiler } from '../helpers/compiler'; import { requestRestart, watchFilesForRestart } from '../restart'; import type { @@ -85,8 +85,13 @@ export async function createDevServer< options: Options, createCompiler: CreateCompiler, config: NormalizedConfig, - { getPortSilently, runCompile = true }: CreateDevServerOptions = {}, + devServerOptions: CreateDevServerOptions = {}, ): Promise { + const { getPortSilently, runCompile = true } = devServerOptions; + const restartContext = { + action: 'dev' as const, + options: pick(devServerOptions, ['getPortSilently']), + }; const { context } = options; const { logger } = context; logger.debug('create dev server'); @@ -228,7 +233,7 @@ export async function createDevServer< // Request a manual restart and close the old watcher only after it succeeds. const restartServer = async () => { const restarted = await requestRestart({ - action: 'dev', + restartContext, clear: false, logger, restartManager: context.restartManager, @@ -455,7 +460,7 @@ export async function createDevServer< state.restartWatcher = watchFilesForRestart({ watchFiles: config.dev.watchFiles, context, - action: 'dev', + restartContext, }); logger.debug('create dev server done'); diff --git a/packages/core/src/types/hooks.ts b/packages/core/src/types/hooks.ts index 8fc735e6a9..dfa9243ed4 100644 --- a/packages/core/src/types/hooks.ts +++ b/packages/core/src/types/hooks.ts @@ -10,7 +10,7 @@ import type { NormalizedEnvironmentConfig, RsbuildConfig, } from './config'; -import type { RsbuildEntry, RsbuildTarget } from './rsbuild'; +import type { BuildOptions, RsbuildEntry, RsbuildTarget, StartDevServerOptions } from './rsbuild'; import type { Rspack } from './rspack'; import type { HtmlRspackPlugin } from './thirdParty'; import type { MaybePromise } from './utils'; @@ -150,16 +150,33 @@ export type OnAfterCreateCompilerFn void; export type RestartContext = { - /** - * The current Rsbuild action being restarted. - */ - action: 'dev' | 'build'; /** * The absolute path of the file that triggered the restart. * It is undefined when the restart is manually triggered. */ filePath?: string; -}; +} & ( + | { + /** + * The current Rsbuild action being restarted. + */ + action: 'build'; + /** + * The options passed to `rsbuild.build()`. + */ + options: BuildOptions; + } + | { + /** + * The current Rsbuild action being restarted. + */ + action: 'dev'; + /** + * The options passed to `rsbuild.startDevServer()`. + */ + options: StartDevServerOptions; + } +); /** Restart the current Rsbuild task and return whether the restart succeeded. */ export type RestartFn = (context: RestartContext) => MaybePromise; diff --git a/packages/core/tests/restartHook.test.ts b/packages/core/tests/restartHook.test.ts index 06f95363b2..ba6faa7d9a 100644 --- a/packages/core/tests/restartHook.test.ts +++ b/packages/core/tests/restartHook.test.ts @@ -3,7 +3,7 @@ import { createRestartManager } from '../src/helpers/restartManager'; describe('restartManager', () => { test('should execute all callbacks and clear the registry when one throws', async () => { const calls: string[] = []; - const context = { action: 'build' } as const; + const context = { action: 'build', options: { watch: true } } as const; const restart = rstest.fn(() => true); const manager = createRestartManager({ onRestart: () => {}, restart }); @@ -32,7 +32,7 @@ describe('restartManager', () => { const unregister = manager.registerCleanup(callback); unregister(); - await manager.requestRestart({ action: 'dev' }); + await manager.requestRestart({ action: 'dev', options: {} }); expect(callback).not.toHaveBeenCalled(); }); diff --git a/website/docs/en/api/javascript-api/core.mdx b/website/docs/en/api/javascript-api/core.mdx index 8f0498e21e..b9c57a015f 100644 --- a/website/docs/en/api/javascript-api/core.mdx +++ b/website/docs/en/api/javascript-api/core.mdx @@ -107,7 +107,7 @@ The `restart` option allows the caller to control how Rsbuild restarts the curre > See [Configuration file watching](/guide/configuration/rsbuild#configuration-file-watching) to learn what triggers a restart. -When a restart is requested, Rsbuild calls the [onRestart hook](/plugins/dev/hooks#onrestart), closes the current task resources, and then calls `restart`. +When a restart is requested, Rsbuild calls the [onRestart hook](/plugins/dev/hooks#onrestart), closes the current task resources, and then calls `restart`. The `restart` callback receives the options passed to the current `rsbuild.build()` or `rsbuild.startDevServer()` call. The function should return `true` when the replacement task starts successfully. If it returns `false` or throws an error, the restart watcher remains active so later file changes can retry the restart. @@ -120,13 +120,13 @@ async function createInstance() { }); } -const restart: RestartFn = async ({ action }) => { +const restart: RestartFn = async (context) => { const rsbuild = await createInstance(); - if (action === 'build') { - await rsbuild.build({ watch: true }); + if (context.action === 'build') { + await rsbuild.build(context.options); } else { - await rsbuild.startDevServer(); + await rsbuild.startDevServer(context.options); } return true; diff --git a/website/docs/en/shared/onRestart.mdx b/website/docs/en/shared/onRestart.mdx index 0e2ea2f248..218003b330 100644 --- a/website/docs/en/shared/onRestart.mdx +++ b/website/docs/en/shared/onRestart.mdx @@ -14,14 +14,23 @@ When using the JavaScript API, restart watchers are installed by `rsbuild.startD ```ts type RestartContext = { - action: 'dev' | 'build'; filePath?: string; -}; +} & ( + | { + action: 'build'; + options: BuildOptions; + } + | { + action: 'dev'; + options: StartDevServerOptions; + } +); function OnRestart(callback: (context: RestartContext) => Promise | void): void; ``` - `action`: The current Rsbuild action being restarted. - `filePath`: The absolute path of the file that triggered the restart. It is `undefined` when the restart is manually triggered. +- `options`: The options passed to the current `rsbuild.build()` or `rsbuild.startDevServer()` call. - **Version:** Added in v2.1.7 diff --git a/website/docs/zh/api/javascript-api/core.mdx b/website/docs/zh/api/javascript-api/core.mdx index 079cb2677f..2dcde7035c 100644 --- a/website/docs/zh/api/javascript-api/core.mdx +++ b/website/docs/zh/api/javascript-api/core.mdx @@ -107,7 +107,7 @@ const rsbuild = await createRsbuild({ > 查看 [配置文件监听](/guide/configuration/rsbuild#configuration-file-watching) 了解重启的触发方式。 -当请求重启时,Rsbuild 会调用 [onRestart hook](/plugins/dev/hooks#onrestart),关闭当前任务的资源,然后调用 `restart`。 +当请求重启时,Rsbuild 会调用 [onRestart hook](/plugins/dev/hooks#onrestart),关闭当前任务的资源,然后调用 `restart`。`restart` 回调会接收到当前调用 `rsbuild.build()` 或 `rsbuild.startDevServer()` 时传入的选项。 新任务成功启动时,该函数应返回 `true`。如果返回 `false` 或抛出错误,restart watcher 会保持运行,以便后续文件变化时再次尝试重启。 @@ -120,13 +120,13 @@ async function createInstance() { }); } -const restart: RestartFn = async ({ action }) => { +const restart: RestartFn = async (context) => { const rsbuild = await createInstance(); - if (action === 'build') { - await rsbuild.build({ watch: true }); + if (context.action === 'build') { + await rsbuild.build(context.options); } else { - await rsbuild.startDevServer(); + await rsbuild.startDevServer(context.options); } return true; diff --git a/website/docs/zh/shared/onRestart.mdx b/website/docs/zh/shared/onRestart.mdx index ddd6282189..6a37a4f494 100644 --- a/website/docs/zh/shared/onRestart.mdx +++ b/website/docs/zh/shared/onRestart.mdx @@ -14,14 +14,23 @@ ```ts type RestartContext = { - action: 'dev' | 'build'; filePath?: string; -}; +} & ( + | { + action: 'build'; + options: BuildOptions; + } + | { + action: 'dev'; + options: StartDevServerOptions; + } +); function OnRestart(callback: (context: RestartContext) => Promise | void): void; ``` - `action`:当前正在重启的 Rsbuild 操作类型。 - `filePath`:触发重启的文件绝对路径,手动触发重启时为 `undefined`。 +- `options`:当前调用 `rsbuild.build()` 或 `rsbuild.startDevServer()` 时传入的选项。 - **版本:** 新增于 v2.1.7