diff --git a/packages/vite/src/node/__tests__/shortcuts.spec.ts b/packages/vite/src/node/__tests__/shortcuts.spec.ts index 90a22f56f8f88d..d2526918551881 100644 --- a/packages/vite/src/node/__tests__/shortcuts.spec.ts +++ b/packages/vite/src/node/__tests__/shortcuts.spec.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest' import { describe, expect, test, vi } from 'vitest' import { createServer } from '../server' import { preview } from '../preview' @@ -26,18 +27,17 @@ describe('bindCLIShortcuts', () => { ) expect.assert( - server._rl, + server._shortcutsState?.rl, 'The readline interface should be defined after binding shortcuts.', ) expect(xAction).not.toHaveBeenCalled() - server._rl.emit('line', 'x') + server._shortcutsState.rl.emit('line', 'x') await vi.waitFor(() => expect(xAction).toHaveBeenCalledOnce()) const xUpdatedAction = vi.fn() const zAction = vi.fn() - xAction.mockClear() bindCLIShortcuts( server, { @@ -50,18 +50,18 @@ describe('bindCLIShortcuts', () => { ) expect(xUpdatedAction).not.toHaveBeenCalled() - server._rl.emit('line', 'x') + server._shortcutsState.rl.emit('line', 'x') await vi.waitFor(() => expect(xUpdatedAction).toHaveBeenCalledOnce()) // Ensure original xAction is not called again - expect(xAction).not.toBeCalled() + expect(xAction).toHaveBeenCalledOnce() expect(yAction).not.toHaveBeenCalled() - server._rl.emit('line', 'y') + server._shortcutsState.rl.emit('line', 'y') await vi.waitFor(() => expect(yAction).toHaveBeenCalledOnce()) expect(zAction).not.toHaveBeenCalled() - server._rl.emit('line', 'z') + server._shortcutsState.rl.emit('line', 'z') await vi.waitFor(() => expect(zAction).toHaveBeenCalledOnce()) } finally { await server.close() @@ -69,45 +69,103 @@ describe('bindCLIShortcuts', () => { }) test('rebinds shortcuts after server restart', async () => { - const server = await createServer() + const manualShortcutAction = vi.fn() + const pluginShortcutActions: Array> = [] + + const server = await createServer({ + plugins: [ + { + name: 'custom-shortcut-plugin', + configureServer(viteDevServer) { + const action = vi.fn() + + // Keep track of actions created by the plugin + // To verify if they are overwritten on server restart + pluginShortcutActions.push(action) + + // Bind custom shortcut from plugin + bindCLIShortcuts( + viteDevServer, + { + customShortcuts: [ + { + key: 'y', + description: 'plugin shortcut', + action, + }, + ], + }, + true, + ) + }, + }, + ], + }) try { - const action = vi.fn() + const readline = server._shortcutsState?.rl + + expect.assert( + readline, + 'The readline interface should be defined after binding shortcuts.', + ) + + readline.emit('line', 'y') + await vi.waitFor(() => { + expect(pluginShortcutActions).toHaveLength(1) + expect(pluginShortcutActions[0]).toHaveBeenCalledOnce() + }) + // Manually bind another custom shortcut bindCLIShortcuts( server, { - customShortcuts: [{ key: 'x', description: 'test', action }], + customShortcuts: [ + { + key: 'x', + description: 'manual shortcut', + action: manualShortcutAction, + }, + ], }, true, ) - // Verify shortcut works initially - const initialReadline = server._rl - - expect.assert( - initialReadline, - 'The readline interface should be defined after binding shortcuts.', + readline.emit('line', 'x') + await vi.waitFor(() => + expect(manualShortcutAction).toHaveBeenCalledOnce(), ) - initialReadline.emit('line', 'x') - - await vi.waitFor(() => expect(action).toHaveBeenCalledOnce()) + // Check the order of shortcuts before restart + expect( + server._shortcutsState?.options.customShortcuts?.map((s) => s.key), + ).toEqual(['x', 'y']) // Restart the server - action.mockClear() await server.restart() - const newReadline = server._rl + // Shortcut orders should be preserved after restart + expect( + server._shortcutsState?.options.customShortcuts?.map((s) => s.key), + ).toEqual(['x', 'y']) expect.assert( - newReadline && newReadline !== initialReadline, - 'A new readline interface should be created after server restart.', + server._shortcutsState?.rl === readline, + 'The readline interface should be preserved.', ) // Shortcuts should still work after restart - newReadline.emit('line', 'x') - await vi.waitFor(() => expect(action).toHaveBeenCalledOnce()) + readline.emit('line', 'x') + await vi.waitFor(() => + expect(manualShortcutAction).toHaveBeenCalledTimes(2), + ) + + readline.emit('line', 'y') + await vi.waitFor(() => { + expect(pluginShortcutActions).toHaveLength(2) + expect(pluginShortcutActions[1]).toHaveBeenCalledOnce() + expect(pluginShortcutActions[0]).toHaveBeenCalledOnce() + }) } finally { await server.close() } diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index 6fc8e914fd69fd..f4db13030c24c1 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -1,6 +1,5 @@ import fs from 'node:fs' import path from 'node:path' -import type readline from 'node:readline' import sirv from 'sirv' import compression from '@polka/compression' import connect from 'connect' @@ -36,7 +35,7 @@ import { } from './utils' import { printServerUrls } from './logger' import { bindCLIShortcuts } from './shortcuts' -import type { BindCLIShortcutsOptions } from './shortcuts' +import type { BindCLIShortcutsOptions, ShortcutsState } from './shortcuts' import { resolveConfig } from './config' import type { InlineConfig, ResolvedConfig } from './config' import { DEFAULT_PREVIEW_PORT } from './constants' @@ -113,11 +112,7 @@ export interface PreviewServer { /** * @internal */ - _shortcutsOptions?: BindCLIShortcutsOptions - /** - * @internal - */ - _rl?: readline.Interface | undefined + _shortcutsState?: ShortcutsState } export type PreviewServerHook = ( diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 06a8ff27773cd0..cf75ea0c6cd2ab 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -6,7 +6,6 @@ import { get as httpsGet } from 'node:https' import type * as http from 'node:http' import { performance } from 'node:perf_hooks' import type { Http2SecureServer } from 'node:http2' -import type readline from 'node:readline' import connect from 'connect' import corsMiddleware from 'cors' import colors from 'picocolors' @@ -46,7 +45,7 @@ import { ssrFixStacktrace, ssrRewriteStacktrace } from '../ssr/ssrStacktrace' import { ssrTransform } from '../ssr/ssrTransform' import { reloadOnTsconfigChange } from '../plugins/esbuild' import { bindCLIShortcuts } from '../shortcuts' -import type { BindCLIShortcutsOptions } from '../shortcuts' +import type { BindCLIShortcutsOptions, ShortcutsState } from '../shortcuts' import { CLIENT_DIR, DEFAULT_DEV_PORT, @@ -407,11 +406,7 @@ export interface ViteDevServer { /** * @internal */ - _shortcutsOptions?: BindCLIShortcutsOptions - /** - * @internal - */ - _rl?: readline.Interface | undefined + _shortcutsState?: ShortcutsState /** * @internal */ @@ -442,6 +437,7 @@ export async function _createServer( options: { listen: boolean previousEnvironments?: Record + previousShortcutsState?: ShortcutsState }, ): Promise { const config = isResolvedConfig(inlineConfig) @@ -773,7 +769,7 @@ export async function _createServer( }, _restartPromise: null, _forceOptimizeOnRestart: false, - _shortcutsOptions: undefined, + _shortcutsState: options.previousShortcutsState, } // maintain consistency with the server instance after restarting. @@ -1215,7 +1211,6 @@ export function resolveServerOptions( async function restartServer(server: ViteDevServer) { global.__vite_start_time = performance.now() - const shortcutsOptions = server._shortcutsOptions let inlineConfig = server.config.inlineConfig if (server._forceOptimizeOnRestart) { @@ -1236,6 +1231,7 @@ async function restartServer(server: ViteDevServer) { newServer = await _createServer(inlineConfig, { listen: false, previousEnvironments: server.environments, + previousShortcutsState: server._shortcutsState, }) } catch (err: any) { server.config.logger.error(err.message, { @@ -1245,14 +1241,15 @@ async function restartServer(server: ViteDevServer) { return } + // Detach readline so close handler skips it. Reused to avoid stdin issues + server._shortcutsState = undefined + await server.close() // Assign new server props to existing server instance const middlewares = server.middlewares newServer._configServerPort = server._configServerPort newServer._currentServerPort = server._currentServerPort - // Ensure the new server has no stale readline reference - newServer._rl = undefined Object.assign(server, newServer) // Keep the same connect instance so app.use(vite.middlewares) works @@ -1277,11 +1274,13 @@ async function restartServer(server: ViteDevServer) { } logger.info('server restarted.', { timestamp: true }) - if (shortcutsOptions) { - shortcutsOptions.print = false + if ( + (server._shortcutsState as ShortcutsState | undefined) + ?.options + ) { bindCLIShortcuts( server, - shortcutsOptions, + { print: false }, // Skip environment checks since shortcuts were bound before restart true, ) diff --git a/packages/vite/src/node/shortcuts.ts b/packages/vite/src/node/shortcuts.ts index bbca13dd58a27a..4583099f4f421c 100644 --- a/packages/vite/src/node/shortcuts.ts +++ b/packages/vite/src/node/shortcuts.ts @@ -6,6 +6,11 @@ import { isDevServer } from './utils' import type { PreviewServer } from './preview' import { openBrowser } from './server/openBrowser' +export type ShortcutsState = { + rl: readline.Interface + options: BindCLIShortcutsOptions +} + export type BindCLIShortcutsOptions = { /** * Print a one-line shortcuts "help" hint to the terminal @@ -36,18 +41,19 @@ export function bindCLIShortcuts( const isDev = isDevServer(server) - const customShortcuts: CLIShortcut[] = - opts?.customShortcuts ?? [] - - // Merge custom shortcuts from existing options - // with new shortcuts taking priority - for (const shortcut of server._shortcutsOptions?.customShortcuts ?? []) { - if (!customShortcuts.some((s) => s.key === shortcut.key)) { - customShortcuts.push(shortcut) - } - } - - server._shortcutsOptions = { + // Merge shortcuts: new at top, existing updated in place (keeps manual > plugin order) + const previousShortcuts = + server._shortcutsState?.options.customShortcuts ?? [] + const newShortcuts = opts?.customShortcuts ?? [] + const previousKeys = new Set(previousShortcuts.map((s) => s.key)) + const customShortcuts: CLIShortcut[] = [ + ...newShortcuts.filter((s) => !previousKeys.has(s.key)), + ...previousShortcuts.map( + (s) => newShortcuts.find((n) => n.key === s.key) ?? s, + ), + ] + + const newOptions: BindCLIShortcutsOptions = { ...opts, customShortcuts, } @@ -100,15 +106,22 @@ export function bindCLIShortcuts( actionRunning = false } - if (!server._rl) { - const rl = readline.createInterface({ input: process.stdin }) - server._rl = rl - server.httpServer.on('close', () => rl.close()) + if (!server._shortcutsState) { + ;(server._shortcutsState as unknown as ShortcutsState) = { + rl: readline.createInterface({ input: process.stdin }), + options: newOptions, + } + server.httpServer.on('close', () => { + // Skip if detached during restart (readline is reused) + if (server._shortcutsState) server._shortcutsState.rl.close() + }) } else { - server._rl.removeAllListeners('line') + server._shortcutsState.rl.removeAllListeners('line') + ;(server._shortcutsState.options as BindCLIShortcutsOptions) = + newOptions } - server._rl.on('line', onInput) + server._shortcutsState!.rl.on('line', onInput) } const BASE_DEV_SHORTCUTS: CLIShortcut[] = [