From f1e44aab45cc0b416b55a4829634f749750a5544 Mon Sep 17 00:00:00 2001 From: beduldul Date: Fri, 25 Sep 2026 08:34:48 +0700 Subject: [PATCH 1/3] fix(server): report ignored server.hmr options when ws is false setupHmrWsOptionCompat() returns early when `server.ws` is `false`, the same way it does for `server.hmr === false`. The early return is needed because the second half of the function aliases the deprecated `server.hmr.*` WebSocket options onto `server.ws.*`, which cannot work when `server.ws` is not an object. But it also skips the branch that reports the deprecated options, so with `server.ws: false` they are accepted, type-checked, and then dropped without a word. That contradicts the docs for `server.hmr`, which say these options "are automatically synced, so existing configurations will continue to work". Someone who sets `server.ws: false` and leaves `server.hmr.port` behind gets no signal that the setting does nothing. Split the combined early return and report the ignored keys through the same `console[method]` / VITE_DEPRECATION_TRACE path the surrounding deprecation warnings already use, deduped per key since this runs on every merge and config-resolution pass. `server.ws: false` still disables the WebSocket server, and the ignored options are still not applied, which is the correct behaviour for that option. Only the silence is removed. Adds two regression tests: one asserting the warning is emitted, one asserting it is not emitted for an `server.hmr` object that carries no WebSocket options. --- .../vite/src/node/__tests__/config.spec.ts | 106 +++++++++++++++--- packages/vite/src/node/utils.ts | 33 +++++- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/packages/vite/src/node/__tests__/config.spec.ts b/packages/vite/src/node/__tests__/config.spec.ts index 6623a6fed92f6e..9484932d609842 100644 --- a/packages/vite/src/node/__tests__/config.spec.ts +++ b/packages/vite/src/node/__tests__/config.spec.ts @@ -1074,28 +1074,35 @@ describe('mergeConfig', () => { }) test('`mergeConfig` does not crash when `server.ws` is false and `server.hmr` is merged', () => { - const baseConfig = defineConfig({ - server: { - ws: false, - hmr: { - host: 'localhost', + // `server.hmr.host` is ignored here and reported by the warning covered in the + // test below. Silence it so it does not consume that warning's per-key dedupe. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const baseConfig = defineConfig({ + server: { + ws: false, + hmr: { + host: 'localhost', + }, }, - }, - }) + }) - const newConfig = defineConfig({ - server: { - hmr: { - port: 5173, + const newConfig = defineConfig({ + server: { + hmr: { + port: 5173, + }, }, - }, - }) + }) - const mergedConfig = mergeConfig(baseConfig, newConfig) + const mergedConfig = mergeConfig(baseConfig, newConfig) - expect(mergedConfig.server.ws).toBe(false) - expect(mergedConfig.server.hmr).toBeTypeOf('object') - expect(mergedConfig.server.hmr).toBeTruthy() + expect(mergedConfig.server.ws).toBe(false) + expect(mergedConfig.server.hmr).toBeTypeOf('object') + expect(mergedConfig.server.hmr).toBeTruthy() + } finally { + warn.mockRestore() + } }) test('resolveConfig properly syncs hmr and ws', async () => { @@ -1122,6 +1129,71 @@ describe('mergeConfig', () => { expect(config.server.hmr.host).toBe('new-host.com') }) + test('warns when `server.hmr` WebSocket options are ignored due to `server.ws: false`', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const trace = vi.spyOn(console, 'trace').mockImplementation(() => {}) + + try { + await resolveConfig( + { + configFile: false, + customLogger: createLogger('silent'), + server: { + ws: false, + hmr: { + host: 'test-host.com', + port: 4000, + }, + }, + }, + 'serve', + ) + + const reported = [...warn.mock.calls, ...trace.mock.calls] + .map((args) => String(args[0])) + .filter((message) => message.includes('ignored because')) + + // Keys are deduped and batched, so a single message may name several of + // them. An earlier test in this file may already have reported one. + expect(reported.length).toBeGreaterThan(0) + const allReported = reported.join('\n') + expect(allReported).toContain('`server.hmr.') + expect(allReported).toMatch(/host|port/) + expect(allReported).toContain('`server.ws` is `false`') + } finally { + warn.mockRestore() + trace.mockRestore() + } + }) + + test('does not warn when `server.hmr` has no WebSocket options', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await resolveConfig( + { + configFile: false, + customLogger: createLogger('silent'), + server: { + ws: false, + hmr: { + overlay: false, + }, + }, + }, + 'serve', + ) + + const reported = warn.mock.calls + .map((args) => String(args[0])) + .filter((message) => message.includes('ignored because')) + + expect(reported).toHaveLength(0) + } finally { + warn.mockRestore() + } + }) + describe('later plugin can read `rollupOptions` set via `rolldownOptions` in earlier plugin', () => { test('top-level config', async () => { expect.assertions(2) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index c587349591a473..ea86dd9afbba92 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -1424,10 +1424,41 @@ const hmrWsOptionsDeprecationCall = /* @__PURE__ */ (() => { } })() +const hmrWsOptionsIgnoredCall = /* @__PURE__ */ (() => { + const logged = new Set() + return (keys: string[]) => { + const pending = keys.filter((key) => !logged.has(key)) + if (pending.length === 0) return + for (const key of pending) logged.add(key) + const method = process.env.VITE_DEPRECATION_TRACE ? 'trace' : 'warn' + // eslint-disable-next-line no-console + console[method]( + `\`server.hmr.${pending.join('/')}\` ${ + pending.length > 1 ? 'are' : 'is' + } ignored because \`server.ws\` is \`false\`. ` + + '`server.ws: false` disables the WebSocket server, so these options have no effect. ' + + 'Remove `server.ws: false` to use them.', + ) + } +})() + export function setupHmrWsOptionCompat( serverConfig: Pick, ): void { - if (serverConfig.hmr === false || serverConfig.ws === false) { + if (serverConfig.hmr === false) { + return + } + if (serverConfig.ws === false) { + // `server.ws: false` turns the WebSocket server off entirely, so the deprecated + // `server.hmr.*` options that are otherwise synced onto `server.ws` cannot be + // honoured here. Report them instead of dropping them without a word. + const ignoredKeys = + serverConfig.hmr === true || !isObject(serverConfig.hmr) + ? [] + : wsOptionKeys.filter((key) => serverConfig.hmr[key] !== undefined) + if (ignoredKeys.length > 0) { + hmrWsOptionsIgnoredCall(ignoredKeys) + } return } if (serverConfig.hmr === true) { From 69a9793778b75cea66a8d74498038dfb5a98e57e Mon Sep 17 00:00:00 2001 From: beduldul Date: Fri, 25 Sep 2026 08:40:58 +0700 Subject: [PATCH 2/3] fix(types): narrow server.hmr before indexing ignored keys The typecheck step of the Lint CI job failed with: src/node/utils.ts(1458,40): error TS18048: 'serverConfig.hmr' is possibly 'undefined'. src/node/utils.ts(1458,40): error TS7053: Element implicitly has an 'any' type because expression of type '"path" | "server" | ...' can't be used to index type 'boolean | HmrOptions'. Capture serverConfig.hmr in a local const and narrow it via isObject() so TypeScript resolves it to HmrOptions when indexing with wsOptionKeys. No behaviour change. --- packages/vite/src/node/utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index ea86dd9afbba92..5dd994c1f5ffe9 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -1452,10 +1452,10 @@ export function setupHmrWsOptionCompat( // `server.ws: false` turns the WebSocket server off entirely, so the deprecated // `server.hmr.*` options that are otherwise synced onto `server.ws` cannot be // honoured here. Report them instead of dropping them without a word. - const ignoredKeys = - serverConfig.hmr === true || !isObject(serverConfig.hmr) - ? [] - : wsOptionKeys.filter((key) => serverConfig.hmr[key] !== undefined) + const hmrOptions = serverConfig.hmr + const ignoredKeys = isObject(hmrOptions) + ? wsOptionKeys.filter((key) => hmrOptions[key] !== undefined) + : [] if (ignoredKeys.length > 0) { hmrWsOptionsIgnoredCall(ignoredKeys) } From f1336221cb0d4ce4f00718a99d72565f76fd9185 Mon Sep 17 00:00:00 2001 From: beduldul Date: Fri, 25 Sep 2026 12:39:31 +0700 Subject: [PATCH 3/3] fix(server): narrow hmr.server warning to serve and hmr.server only - warn about ignored `server.hmr.server` only when `server.ws === false`, `command === 'serve'` and `server.hmr.server` is set; no longer reports the other deprecated `server.hmr.*` keys - forward `isPreview` from `resolveConfig` into `resolveServerOptions` so `vite preview` (which has no HMR server) does not emit the warning, and keep `mergeConfig` and `vite build` silent - add a test asserting no warning on the preview path --- .../vite/src/node/__tests__/config.spec.ts | 150 +++++++++++++----- packages/vite/src/node/config.ts | 8 +- packages/vite/src/node/server/index.ts | 4 +- packages/vite/src/node/utils.ts | 41 ++--- 4 files changed, 147 insertions(+), 56 deletions(-) diff --git a/packages/vite/src/node/__tests__/config.spec.ts b/packages/vite/src/node/__tests__/config.spec.ts index 9484932d609842..f4f091b1eee6a9 100644 --- a/packages/vite/src/node/__tests__/config.spec.ts +++ b/packages/vite/src/node/__tests__/config.spec.ts @@ -1074,35 +1074,54 @@ describe('mergeConfig', () => { }) test('`mergeConfig` does not crash when `server.ws` is false and `server.hmr` is merged', () => { - // `server.hmr.host` is ignored here and reported by the warning covered in the - // test below. Silence it so it does not consume that warning's per-key dedupe. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const baseConfig = defineConfig({ - server: { - ws: false, - hmr: { - host: 'localhost', - }, + const baseConfig = defineConfig({ + server: { + ws: false, + hmr: { + host: 'localhost', }, - }) + }, + }) - const newConfig = defineConfig({ - server: { - hmr: { - port: 5173, - }, + const newConfig = defineConfig({ + server: { + hmr: { + port: 5173, }, - }) + }, + }) - const mergedConfig = mergeConfig(baseConfig, newConfig) + const mergedConfig = mergeConfig(baseConfig, newConfig) + + expect(mergedConfig.server.ws).toBe(false) + expect(mergedConfig.server.hmr).toBeTypeOf('object') + expect(mergedConfig.server.hmr).toBeTruthy() + }) - expect(mergedConfig.server.ws).toBe(false) - expect(mergedConfig.server.hmr).toBeTypeOf('object') - expect(mergedConfig.server.hmr).toBeTruthy() + test('does not warn about ignored `server.hmr.server` during preview', async () => { + const warnFn = vi.fn() + const originalWarn = console.warn + console.warn = warnFn + try { + await resolveConfig( + { + configFile: false, + server: { + ws: false, + hmr: { + server: http.createServer(), + }, + }, + } as InlineConfig, + 'serve', + 'production', + 'production', + true, + ) } finally { - warn.mockRestore() + console.warn = originalWarn } + expect(warnFn).not.toHaveBeenCalled() }) test('resolveConfig properly syncs hmr and ws', async () => { @@ -1129,11 +1148,14 @@ describe('mergeConfig', () => { expect(config.server.hmr.host).toBe('new-host.com') }) - test('warns when `server.hmr` WebSocket options are ignored due to `server.ws: false`', async () => { + test('warns when `server.hmr.server` is ignored due to `server.ws: false`', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const trace = vi.spyOn(console, 'trace').mockImplementation(() => {}) try { + // a fresh module instance has a fresh "already warned" state + vi.resetModules() + const { resolveConfig } = await import('../config') await resolveConfig( { configFile: false, @@ -1141,8 +1163,7 @@ describe('mergeConfig', () => { server: { ws: false, hmr: { - host: 'test-host.com', - port: 4000, + server: http.createServer(), }, }, }, @@ -1151,25 +1172,52 @@ describe('mergeConfig', () => { const reported = [...warn.mock.calls, ...trace.mock.calls] .map((args) => String(args[0])) - .filter((message) => message.includes('ignored because')) - - // Keys are deduped and batched, so a single message may name several of - // them. An earlier test in this file may already have reported one. - expect(reported.length).toBeGreaterThan(0) - const allReported = reported.join('\n') - expect(allReported).toContain('`server.hmr.') - expect(allReported).toMatch(/host|port/) - expect(allReported).toContain('`server.ws` is `false`') + .filter((message) => message.includes('`server.hmr.server` is ignored')) + + expect(reported).toHaveLength(1) } finally { warn.mockRestore() trace.mockRestore() } }) - test('does not warn when `server.hmr` has no WebSocket options', async () => { + test('does not warn for `server.hmr` options that `server.ws: false` implies are ignored', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + vi.resetModules() + const { resolveConfig } = await import('../config') + await resolveConfig( + { + configFile: false, + customLogger: createLogger('silent'), + server: { + ws: false, + hmr: { + host: 'test-host.com', + port: 4000, + }, + }, + }, + 'serve', + ) + + const reported = warn.mock.calls + .map((args) => String(args[0])) + .filter((message) => message.includes('is ignored')) + + expect(reported).toHaveLength(0) + } finally { + warn.mockRestore() + } + }) + + test('does not warn when `server.hmr` has no HMR server', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { + vi.resetModules() + const { resolveConfig } = await import('../config') await resolveConfig( { configFile: false, @@ -1186,7 +1234,37 @@ describe('mergeConfig', () => { const reported = warn.mock.calls .map((args) => String(args[0])) - .filter((message) => message.includes('ignored because')) + .filter((message) => message.includes('is ignored')) + + expect(reported).toHaveLength(0) + } finally { + warn.mockRestore() + } + }) + + test('does not warn for `server.hmr.server` on build', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + vi.resetModules() + const { resolveConfig } = await import('../config') + await resolveConfig( + { + configFile: false, + customLogger: createLogger('silent'), + server: { + ws: false, + hmr: { + server: http.createServer(), + }, + }, + }, + 'build', + ) + + const reported = warn.mock.calls + .map((args) => String(args[0])) + .filter((message) => message.includes('is ignored')) expect(reported).toHaveLength(0) } finally { diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 41f72966f9d666..fbd468edf547ca 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -1920,7 +1920,13 @@ export async function resolveConfig( : '' const input = normalizeInput(config.input) - const server = await resolveServerOptions(resolvedRoot, config.server, logger) + const server = await resolveServerOptions( + resolvedRoot, + config.server, + logger, + command, + isPreview, + ) const builder = resolveBuilderOptions(config.builder) diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 2779716cbc5899..53c31eb00ce955 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -1258,6 +1258,8 @@ export async function resolveServerOptions( root: string, raw: ServerOptions | undefined, logger: Logger, + command: 'build' | 'serve' = 'serve', + isPreview = false, ): Promise { const _server = mergeWithDefaults( { @@ -1268,7 +1270,7 @@ export async function resolveServerOptions( raw ?? {}, ) - setupHmrWsOptionCompat(_server) + setupHmrWsOptionCompat(_server, command, isPreview) const workspaceRoot = searchForWorkspaceRoot(root) const server: ResolvedServerOptions = { diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 5dd994c1f5ffe9..72eea777ceb36b 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -1424,26 +1424,25 @@ const hmrWsOptionsDeprecationCall = /* @__PURE__ */ (() => { } })() -const hmrWsOptionsIgnoredCall = /* @__PURE__ */ (() => { - const logged = new Set() - return (keys: string[]) => { - const pending = keys.filter((key) => !logged.has(key)) - if (pending.length === 0) return - for (const key of pending) logged.add(key) +const hmrServerIgnoredCall = /* @__PURE__ */ (() => { + let logged = false + return () => { + if (logged) return + logged = true const method = process.env.VITE_DEPRECATION_TRACE ? 'trace' : 'warn' // eslint-disable-next-line no-console console[method]( - `\`server.hmr.${pending.join('/')}\` ${ - pending.length > 1 ? 'are' : 'is' - } ignored because \`server.ws\` is \`false\`. ` + - '`server.ws: false` disables the WebSocket server, so these options have no effect. ' + - 'Remove `server.ws: false` to use them.', + '`server.ws` is `false`, so `server.hmr.server` is ignored. ' + + '`server.hmr.server` is the transport used for HMR, which `server.ws: false` turns off. ' + + 'Remove `server.ws: false`, or use `server.ws.server` instead.', ) } })() export function setupHmrWsOptionCompat( serverConfig: Pick, + command?: 'build' | 'serve', + isPreview = false, ): void { if (serverConfig.hmr === false) { return @@ -1451,13 +1450,19 @@ export function setupHmrWsOptionCompat( if (serverConfig.ws === false) { // `server.ws: false` turns the WebSocket server off entirely, so the deprecated // `server.hmr.*` options that are otherwise synced onto `server.ws` cannot be - // honoured here. Report them instead of dropping them without a word. - const hmrOptions = serverConfig.hmr - const ignoredKeys = isObject(hmrOptions) - ? wsOptionKeys.filter((key) => hmrOptions[key] !== undefined) - : [] - if (ignoredKeys.length > 0) { - hmrWsOptionsIgnoredCall(ignoredKeys) + // honoured here. Only `server.hmr.server` is worth reporting: it is the HMR + // transport, not a socket parameter, so losing it is not implied by `server.ws: false` + // the way losing e.g. `server.hmr.port` is. + // The warning is only meaningful when a dev server is started, so it is skipped for + // `vite build`, for `vite preview` (which serves static files and has no HMR), and + // for bare `mergeConfig` (which passes no `command`). + if ( + command === 'serve' && + !isPreview && + isObject(serverConfig.hmr) && + serverConfig.hmr.server !== undefined + ) { + hmrServerIgnoredCall() } return }