Skip to content

Commit 721f163

Browse files
fix: plugin shortcut support (#21211)
Co-authored-by: sapphi-red <[email protected]>
1 parent 6d5a4a9 commit 721f163

File tree

4 files changed

+129
-64
lines changed

4 files changed

+129
-64
lines changed

packages/vite/src/node/__tests__/shortcuts.spec.ts

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Mock } from 'vitest'
12
import { describe, expect, test, vi } from 'vitest'
23
import { createServer } from '../server'
34
import { preview } from '../preview'
@@ -26,18 +27,17 @@ describe('bindCLIShortcuts', () => {
2627
)
2728

2829
expect.assert(
29-
server._rl,
30+
server._shortcutsState?.rl,
3031
'The readline interface should be defined after binding shortcuts.',
3132
)
3233
expect(xAction).not.toHaveBeenCalled()
3334

34-
server._rl.emit('line', 'x')
35+
server._shortcutsState.rl.emit('line', 'x')
3536
await vi.waitFor(() => expect(xAction).toHaveBeenCalledOnce())
3637

3738
const xUpdatedAction = vi.fn()
3839
const zAction = vi.fn()
3940

40-
xAction.mockClear()
4141
bindCLIShortcuts(
4242
server,
4343
{
@@ -50,64 +50,122 @@ describe('bindCLIShortcuts', () => {
5050
)
5151

5252
expect(xUpdatedAction).not.toHaveBeenCalled()
53-
server._rl.emit('line', 'x')
53+
server._shortcutsState.rl.emit('line', 'x')
5454
await vi.waitFor(() => expect(xUpdatedAction).toHaveBeenCalledOnce())
5555

5656
// Ensure original xAction is not called again
57-
expect(xAction).not.toBeCalled()
57+
expect(xAction).toHaveBeenCalledOnce()
5858

5959
expect(yAction).not.toHaveBeenCalled()
60-
server._rl.emit('line', 'y')
60+
server._shortcutsState.rl.emit('line', 'y')
6161
await vi.waitFor(() => expect(yAction).toHaveBeenCalledOnce())
6262

6363
expect(zAction).not.toHaveBeenCalled()
64-
server._rl.emit('line', 'z')
64+
server._shortcutsState.rl.emit('line', 'z')
6565
await vi.waitFor(() => expect(zAction).toHaveBeenCalledOnce())
6666
} finally {
6767
await server.close()
6868
}
6969
})
7070

7171
test('rebinds shortcuts after server restart', async () => {
72-
const server = await createServer()
72+
const manualShortcutAction = vi.fn()
73+
const pluginShortcutActions: Array<Mock<any>> = []
74+
75+
const server = await createServer({
76+
plugins: [
77+
{
78+
name: 'custom-shortcut-plugin',
79+
configureServer(viteDevServer) {
80+
const action = vi.fn()
81+
82+
// Keep track of actions created by the plugin
83+
// To verify if they are overwritten on server restart
84+
pluginShortcutActions.push(action)
85+
86+
// Bind custom shortcut from plugin
87+
bindCLIShortcuts(
88+
viteDevServer,
89+
{
90+
customShortcuts: [
91+
{
92+
key: 'y',
93+
description: 'plugin shortcut',
94+
action,
95+
},
96+
],
97+
},
98+
true,
99+
)
100+
},
101+
},
102+
],
103+
})
73104

74105
try {
75-
const action = vi.fn()
106+
const readline = server._shortcutsState?.rl
107+
108+
expect.assert(
109+
readline,
110+
'The readline interface should be defined after binding shortcuts.',
111+
)
112+
113+
readline.emit('line', 'y')
114+
await vi.waitFor(() => {
115+
expect(pluginShortcutActions).toHaveLength(1)
116+
expect(pluginShortcutActions[0]).toHaveBeenCalledOnce()
117+
})
76118

119+
// Manually bind another custom shortcut
77120
bindCLIShortcuts(
78121
server,
79122
{
80-
customShortcuts: [{ key: 'x', description: 'test', action }],
123+
customShortcuts: [
124+
{
125+
key: 'x',
126+
description: 'manual shortcut',
127+
action: manualShortcutAction,
128+
},
129+
],
81130
},
82131
true,
83132
)
84133

85-
// Verify shortcut works initially
86-
const initialReadline = server._rl
87-
88-
expect.assert(
89-
initialReadline,
90-
'The readline interface should be defined after binding shortcuts.',
134+
readline.emit('line', 'x')
135+
await vi.waitFor(() =>
136+
expect(manualShortcutAction).toHaveBeenCalledOnce(),
91137
)
92138

93-
initialReadline.emit('line', 'x')
94-
95-
await vi.waitFor(() => expect(action).toHaveBeenCalledOnce())
139+
// Check the order of shortcuts before restart
140+
expect(
141+
server._shortcutsState?.options.customShortcuts?.map((s) => s.key),
142+
).toEqual(['x', 'y'])
96143

97144
// Restart the server
98-
action.mockClear()
99145
await server.restart()
100146

101-
const newReadline = server._rl
147+
// Shortcut orders should be preserved after restart
148+
expect(
149+
server._shortcutsState?.options.customShortcuts?.map((s) => s.key),
150+
).toEqual(['x', 'y'])
102151

103152
expect.assert(
104-
newReadline && newReadline !== initialReadline,
105-
'A new readline interface should be created after server restart.',
153+
server._shortcutsState?.rl === readline,
154+
'The readline interface should be preserved.',
106155
)
107156

108157
// Shortcuts should still work after restart
109-
newReadline.emit('line', 'x')
110-
await vi.waitFor(() => expect(action).toHaveBeenCalledOnce())
158+
readline.emit('line', 'x')
159+
await vi.waitFor(() =>
160+
expect(manualShortcutAction).toHaveBeenCalledTimes(2),
161+
)
162+
163+
readline.emit('line', 'y')
164+
await vi.waitFor(() => {
165+
expect(pluginShortcutActions).toHaveLength(2)
166+
expect(pluginShortcutActions[1]).toHaveBeenCalledOnce()
167+
expect(pluginShortcutActions[0]).toHaveBeenCalledOnce()
168+
})
111169
} finally {
112170
await server.close()
113171
}

packages/vite/src/node/preview.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
3-
import type readline from 'node:readline'
43
import sirv from 'sirv'
54
import compression from '@polka/compression'
65
import connect from 'connect'
@@ -36,7 +35,7 @@ import {
3635
} from './utils'
3736
import { printServerUrls } from './logger'
3837
import { bindCLIShortcuts } from './shortcuts'
39-
import type { BindCLIShortcutsOptions } from './shortcuts'
38+
import type { BindCLIShortcutsOptions, ShortcutsState } from './shortcuts'
4039
import { resolveConfig } from './config'
4140
import type { InlineConfig, ResolvedConfig } from './config'
4241
import { DEFAULT_PREVIEW_PORT } from './constants'
@@ -113,11 +112,7 @@ export interface PreviewServer {
113112
/**
114113
* @internal
115114
*/
116-
_shortcutsOptions?: BindCLIShortcutsOptions<PreviewServer>
117-
/**
118-
* @internal
119-
*/
120-
_rl?: readline.Interface | undefined
115+
_shortcutsState?: ShortcutsState<PreviewServer>
121116
}
122117

123118
export type PreviewServerHook = (

packages/vite/src/node/server/index.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { get as httpsGet } from 'node:https'
66
import type * as http from 'node:http'
77
import { performance } from 'node:perf_hooks'
88
import type { Http2SecureServer } from 'node:http2'
9-
import type readline from 'node:readline'
109
import connect from 'connect'
1110
import corsMiddleware from 'cors'
1211
import colors from 'picocolors'
@@ -46,7 +45,7 @@ import { ssrFixStacktrace, ssrRewriteStacktrace } from '../ssr/ssrStacktrace'
4645
import { ssrTransform } from '../ssr/ssrTransform'
4746
import { reloadOnTsconfigChange } from '../plugins/esbuild'
4847
import { bindCLIShortcuts } from '../shortcuts'
49-
import type { BindCLIShortcutsOptions } from '../shortcuts'
48+
import type { BindCLIShortcutsOptions, ShortcutsState } from '../shortcuts'
5049
import {
5150
CLIENT_DIR,
5251
DEFAULT_DEV_PORT,
@@ -407,11 +406,7 @@ export interface ViteDevServer {
407406
/**
408407
* @internal
409408
*/
410-
_shortcutsOptions?: BindCLIShortcutsOptions<ViteDevServer>
411-
/**
412-
* @internal
413-
*/
414-
_rl?: readline.Interface | undefined
409+
_shortcutsState?: ShortcutsState<ViteDevServer>
415410
/**
416411
* @internal
417412
*/
@@ -442,6 +437,7 @@ export async function _createServer(
442437
options: {
443438
listen: boolean
444439
previousEnvironments?: Record<string, DevEnvironment>
440+
previousShortcutsState?: ShortcutsState<ViteDevServer>
445441
},
446442
): Promise<ViteDevServer> {
447443
const config = isResolvedConfig(inlineConfig)
@@ -773,7 +769,7 @@ export async function _createServer(
773769
},
774770
_restartPromise: null,
775771
_forceOptimizeOnRestart: false,
776-
_shortcutsOptions: undefined,
772+
_shortcutsState: options.previousShortcutsState,
777773
}
778774

779775
// maintain consistency with the server instance after restarting.
@@ -1215,7 +1211,6 @@ export function resolveServerOptions(
12151211

12161212
async function restartServer(server: ViteDevServer) {
12171213
global.__vite_start_time = performance.now()
1218-
const shortcutsOptions = server._shortcutsOptions
12191214

12201215
let inlineConfig = server.config.inlineConfig
12211216
if (server._forceOptimizeOnRestart) {
@@ -1236,6 +1231,7 @@ async function restartServer(server: ViteDevServer) {
12361231
newServer = await _createServer(inlineConfig, {
12371232
listen: false,
12381233
previousEnvironments: server.environments,
1234+
previousShortcutsState: server._shortcutsState,
12391235
})
12401236
} catch (err: any) {
12411237
server.config.logger.error(err.message, {
@@ -1245,14 +1241,15 @@ async function restartServer(server: ViteDevServer) {
12451241
return
12461242
}
12471243

1244+
// Detach readline so close handler skips it. Reused to avoid stdin issues
1245+
server._shortcutsState = undefined
1246+
12481247
await server.close()
12491248

12501249
// Assign new server props to existing server instance
12511250
const middlewares = server.middlewares
12521251
newServer._configServerPort = server._configServerPort
12531252
newServer._currentServerPort = server._currentServerPort
1254-
// Ensure the new server has no stale readline reference
1255-
newServer._rl = undefined
12561253
Object.assign(server, newServer)
12571254

12581255
// Keep the same connect instance so app.use(vite.middlewares) works
@@ -1277,11 +1274,13 @@ async function restartServer(server: ViteDevServer) {
12771274
}
12781275
logger.info('server restarted.', { timestamp: true })
12791276

1280-
if (shortcutsOptions) {
1281-
shortcutsOptions.print = false
1277+
if (
1278+
(server._shortcutsState as ShortcutsState<ViteDevServer> | undefined)
1279+
?.options
1280+
) {
12821281
bindCLIShortcuts(
12831282
server,
1284-
shortcutsOptions,
1283+
{ print: false },
12851284
// Skip environment checks since shortcuts were bound before restart
12861285
true,
12871286
)

packages/vite/src/node/shortcuts.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { isDevServer } from './utils'
66
import type { PreviewServer } from './preview'
77
import { openBrowser } from './server/openBrowser'
88

9+
export type ShortcutsState<Server = ViteDevServer | PreviewServer> = {
10+
rl: readline.Interface
11+
options: BindCLIShortcutsOptions<Server>
12+
}
13+
914
export type BindCLIShortcutsOptions<Server = ViteDevServer | PreviewServer> = {
1015
/**
1116
* Print a one-line shortcuts "help" hint to the terminal
@@ -36,18 +41,19 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
3641

3742
const isDev = isDevServer(server)
3843

39-
const customShortcuts: CLIShortcut<ViteDevServer | PreviewServer>[] =
40-
opts?.customShortcuts ?? []
41-
42-
// Merge custom shortcuts from existing options
43-
// with new shortcuts taking priority
44-
for (const shortcut of server._shortcutsOptions?.customShortcuts ?? []) {
45-
if (!customShortcuts.some((s) => s.key === shortcut.key)) {
46-
customShortcuts.push(shortcut)
47-
}
48-
}
49-
50-
server._shortcutsOptions = {
44+
// Merge shortcuts: new at top, existing updated in place (keeps manual > plugin order)
45+
const previousShortcuts =
46+
server._shortcutsState?.options.customShortcuts ?? []
47+
const newShortcuts = opts?.customShortcuts ?? []
48+
const previousKeys = new Set(previousShortcuts.map((s) => s.key))
49+
const customShortcuts: CLIShortcut<ViteDevServer | PreviewServer>[] = [
50+
...newShortcuts.filter((s) => !previousKeys.has(s.key)),
51+
...previousShortcuts.map(
52+
(s) => newShortcuts.find((n) => n.key === s.key) ?? s,
53+
),
54+
]
55+
56+
const newOptions: BindCLIShortcutsOptions<Server> = {
5157
...opts,
5258
customShortcuts,
5359
}
@@ -100,15 +106,22 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
100106
actionRunning = false
101107
}
102108

103-
if (!server._rl) {
104-
const rl = readline.createInterface({ input: process.stdin })
105-
server._rl = rl
106-
server.httpServer.on('close', () => rl.close())
109+
if (!server._shortcutsState) {
110+
;(server._shortcutsState as unknown as ShortcutsState<Server>) = {
111+
rl: readline.createInterface({ input: process.stdin }),
112+
options: newOptions,
113+
}
114+
server.httpServer.on('close', () => {
115+
// Skip if detached during restart (readline is reused)
116+
if (server._shortcutsState) server._shortcutsState.rl.close()
117+
})
107118
} else {
108-
server._rl.removeAllListeners('line')
119+
server._shortcutsState.rl.removeAllListeners('line')
120+
;(server._shortcutsState.options as BindCLIShortcutsOptions<Server>) =
121+
newOptions
109122
}
110123

111-
server._rl.on('line', onInput)
124+
server._shortcutsState!.rl.on('line', onInput)
112125
}
113126

114127
const BASE_DEV_SHORTCUTS: CLIShortcut<ViteDevServer>[] = [

0 commit comments

Comments
 (0)