Skip to content

fix(server): report ignored server.hmr options when ws is false - #23578

Open
beduldul wants to merge 3 commits into
vitejs:mainfrom
beduldul:fix/hmr-ws-options-ignored-when-ws-false
Open

beduldul wants to merge 3 commits into
vitejs:mainfrom
beduldul:fix/hmr-ws-options-ignored-when-ws-false

Conversation

@beduldul

@beduldul beduldul commented Sep 25, 2026 •

Copy link
Copy Markdown

What is this PR solving?

setupHmrWsOptionCompat() returns early when server.ws is false, in the same branch as server.hmr === false. That early return exists because the rest of the function aliases the deprecated server.hmr.* options onto server.ws.*, which cannot work when server.ws isn't an object. The problem is that it also skips the warning. With server.ws: false, those server.hmr.* options are accepted, type-checked, and then dropped with no message at all.

Reproduction

Using server.hmr.server, not host/port, since host/port no longer warn:

import http from 'node:http'
import { resolveConfig } from 'vite'

const config = await resolveConfig(
  {
    configFile: false,
    server: {
      ws: false,
      hmr: { server: http.createServer() },
    },
  },
  'serve',
)

// ws: false  ->  false, and an ignored `server.hmr.server` is reported
// ws unset   ->  { server: <http.Server> }
console.log(config.server.ws)

Observed before the fix: server.ws is false, the custom server is silently never attached, and nothing is printed. Expected: the same, plus a warning naming the option that was ignored and why.

The same interaction shows up through mergeConfig, since that path reaches setupHmrWsOptionCompat too:

import http from 'node:http'
import { mergeConfig } from 'vite'

const merged = mergeConfig(
  { server: { ws: false, hmr: { server: http.createServer() } } },
  { server: { hmr: { port: 5173 } } },
)

console.log(Object.getOwnPropertyDescriptor(merged.server.hmr, 'port'))
// undefined with ws: false, accessor with ws unset

host and port appear in that snippet precisely because they no longer warn — it shows the descriptor behaviour that motivated the first revision, not a warning that fires today.

What the fix does

The warning now fires only for server.hmr.server, and only when a dev server actually starts. The guard is:

command === 'serve' && !isPreview && isObject(serverConfig.hmr) && serverConfig.hmr.server !== undefined

server.hmr.server survives the narrowing because it isn't a socket parameter, it's the HMR transport. docs/config/server-options.md:216 — "When server.ws.server is defined, Vite will process the WebSocket connection requests through the provided server." With ws: false, createWebSocketServer returns a noop stub (guard at ws.ts:123, stub spanning ws.ts:124-144, closing at ws.ts:145), so wsOptions / wsCustomServer at ws.ts:149-150 are never reached. Losing a port, host, or path is inferable from writing ws: false; losing the transport is not.

The other six socket keys (protocol, host, port, clientPort, path, timeout) no longer warn at all — they were dropped after review, because writing server.ws: false already means there is no socket to configure.

preview.ts:138 calls resolveConfig(inlineConfig, 'serve', 'production', 'production', true) — the fifth positional argument is a bare true, which is isPreview. That is why isPreview has to be threaded through into resolveServerOptions (server/index.ts:1262 signature, forwarded at server/index.ts:1273), and why a serve-only gate was not enough: preview has no HMR, so an ungated warning would fire there too.

The ignored options still do not take effect. server.ws: false keeps the WebSocket server disabled; this change only reports that they were never going to be applied.

Bare mergeConfig passes no command (utils.ts:1513-1515), so it stays silent. That is code-path reasoning, not something covered by a test.

Alternatives considered

  • Document the interaction instead of warning. Rejected because a doc line does not surface at the moment the user is staring at a custom server that never gets attached.
  • Warn unconditionally. Rejected because it fires on build and preview, where it is meaningless. resolveServerOptions is called unconditionally (config.ts:1923-1929), so the gating has to be explicit. The first version of this change warned on vite build.
  • Warn for all seven keys. This was the first version of this PR. It was narrowed after review by Vite core member sapphi-red, who wrote: "If they added server.ws: false then, I don't think ignoring server.hmr.port is a problem as there's no "port" to configure." Note that server.hmr.port is a socket parameter, while server.hmr.server is the transport — the six socket keys were dropped on that basis, and server.hmr.server is the one kept.

Tests

In packages/vite/src/node/__tests__/config.spec.ts:

  • :1151 — warns when server.hmr.server is ignored due to server.ws: false
  • :1184 — does not warn for server.hmr options that server.ws: false implies are ignored. This covers host and port only; the other socket keys are not test-covered.
  • :1245 — does not warn for server.hmr.server on build
  • :1101 — does not warn about ignored server.hmr.server during preview
  • :1215 — does not warn when server.hmr has no HMR server

The pre-existing test at config.spec.ts:1076-1099 is byte-identical to the pre-PR original (compared against f1e44aab4~1); it has no console.warn spy and was not changed by this PR. Bare mergeConfig silence is not covered by a test.

Measured locally: config.spec.ts — 124 passed. Wider node suite — 945 passed, 3 skipped, 63 files. Formatting was checked with oxfmt --check on the four changed files, not repo-wide.

Why this was missed

There's already a test for the sync, resolveConfig properly syncs hmr and ws, and it does cover the alias working. It only exercises the path where server.ws is left unset, so the ws: false branch was never checked. #23511 looked at this same early return for #23506, but that report was about a TypeError from merging, and the fix only skipped the missing descriptor. The test added alongside it asserts the merge doesn't throw and nothing about the options surviving, so the silent discard stayed.

AI disclosure

I used AI tools to help investigate this and to draft the change. I reviewed the resulting code, reproduced the bug myself at 39ddf7ccf7e7469ff6a3ba37bca38c32ea804d6e with a scratch spec before touching anything, and confirmed the new tests fail on unmodified main and pass with the change — the diff is in utils.ts plus the tests, and I understand and can explain all of it.

@sapphi-red

Copy link
Copy Markdown
Member

but silently discarding config someone wrote is a bad way to do it. Someone who moves to server.ws: false and leaves server.hmr.port behind has no way to tell their setting stopped doing anything.

If they added server.ws: false then, I don't think ignoring server.hmr.port is a problem as there's no "port" to configure.

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.
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.
- 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
@beduldul
beduldul force-pushed the fix/hmr-ws-options-ignored-when-ws-false branch from 940415f to f133622 Compare September 25, 2026 07:43
@beduldul

Copy link
Copy Markdown
Author

You're right — with server.ws: false there is no port to configure, so warning about server.hmr.port was the wrong call. I've narrowed the warning to server.hmr.server only.

The one case I'd keep it for is that server.hmr.server isn't a socket parameter, it's the HMR transport (docs/config/server-options.md:216). With ws: false, createWebSocketServer returns the noop stub (server/ws.ts:123-144), so wsOptions.server at ws.ts:149-150 is never read:

ws: false + hmr: { server: myServer }  ->  ws stays false, config.server.hmr.server still set, never gets an upgrade listener
ws unset                              ->  config.server.ws.server === myServer

Losing port/host/path is inferable from writing ws: false; losing the transport isn't. It's a narrow gap, not a serious one.

You were also right about the gates: the first version warned on vite build, and a serve-only gate still warned on vite preview, because preview.ts:138 calls resolveConfig(inlineConfig, 'serve', 'production', 'production', true) — the fifth positional argument is a bare true, which is isPreview, and preview has no HMR. isPreview is now threaded into resolveServerOptions.

Pushed as f1336221c, ready for re-review.

Happy to drop hmr.server too if you think it isn't worth warning about.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants