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
108 changes: 83 additions & 25 deletions packages/vite/src/node/__tests__/shortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Mock } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { createServer } from '../server'
import { preview } from '../preview'
Expand Down Expand Up @@ -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,
{
Expand All @@ -50,64 +50,122 @@ 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()
}
})

test('rebinds shortcuts after server restart', async () => {
const server = await createServer()
const manualShortcutAction = vi.fn()
const pluginShortcutActions: Array<Mock<any>> = []

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()
}
Expand Down
9 changes: 2 additions & 7 deletions packages/vite/src/node/preview.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -113,11 +112,7 @@ export interface PreviewServer {
/**
* @internal
*/
_shortcutsOptions?: BindCLIShortcutsOptions<PreviewServer>
/**
* @internal
*/
_rl?: readline.Interface | undefined
_shortcutsState?: ShortcutsState<PreviewServer>
}

export type PreviewServerHook = (
Expand Down
27 changes: 13 additions & 14 deletions packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -407,11 +406,7 @@ export interface ViteDevServer {
/**
* @internal
*/
_shortcutsOptions?: BindCLIShortcutsOptions<ViteDevServer>
/**
* @internal
*/
_rl?: readline.Interface | undefined
_shortcutsState?: ShortcutsState<ViteDevServer>
/**
* @internal
*/
Expand Down Expand Up @@ -442,6 +437,7 @@ export async function _createServer(
options: {
listen: boolean
previousEnvironments?: Record<string, DevEnvironment>
previousShortcutsState?: ShortcutsState<ViteDevServer>
},
): Promise<ViteDevServer> {
const config = isResolvedConfig(inlineConfig)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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, {
Expand All @@ -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
Expand All @@ -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<ViteDevServer> | undefined)
?.options
) {
bindCLIShortcuts(
server,
shortcutsOptions,
{ print: false },
// Skip environment checks since shortcuts were bound before restart
true,
)
Expand Down
49 changes: 31 additions & 18 deletions packages/vite/src/node/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { isDevServer } from './utils'
import type { PreviewServer } from './preview'
import { openBrowser } from './server/openBrowser'

export type ShortcutsState<Server = ViteDevServer | PreviewServer> = {
rl: readline.Interface
options: BindCLIShortcutsOptions<Server>
}

export type BindCLIShortcutsOptions<Server = ViteDevServer | PreviewServer> = {
/**
* Print a one-line shortcuts "help" hint to the terminal
Expand Down Expand Up @@ -36,18 +41,19 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(

const isDev = isDevServer(server)

const customShortcuts: CLIShortcut<ViteDevServer | PreviewServer>[] =
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<ViteDevServer | PreviewServer>[] = [
...newShortcuts.filter((s) => !previousKeys.has(s.key)),
...previousShortcuts.map(
(s) => newShortcuts.find((n) => n.key === s.key) ?? s,
),
]

const newOptions: BindCLIShortcutsOptions<Server> = {
...opts,
customShortcuts,
}
Expand Down Expand Up @@ -100,15 +106,22 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
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<Server>) = {
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<Server>) =
newOptions
}

server._rl.on('line', onInput)
server._shortcutsState!.rl.on('line', onInput)
}

const BASE_DEV_SHORTCUTS: CLIShortcut<ViteDevServer>[] = [
Expand Down