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
24 changes: 8 additions & 16 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ErrorPayload, HotPayload } from 'types/hmrPayload'
import type { ViteHotContext } from 'types/hot'
import type { InferCustomEventPayload } from 'types/customEvent'
import { HMRClient, HMRContext } from '../shared/hmr'
import {
createWebSocketModuleRunnerTransport,
normalizeModuleRunnerTransport,
} from '../shared/moduleRunnerTransport'
import { createHMRHandler } from '../shared/hmrHandler'
import { ErrorOverlay, overlayId } from './overlay'
import '@vite/env'

Expand Down Expand Up @@ -166,15 +166,15 @@ const hmrClient = new HMRClient(
return await importPromise
},
)
transport.connect!(handleMessage)
transport.connect!(createHMRHandler(handleMessage))

async function handleMessage(payload: HotPayload) {
switch (payload.type) {
case 'connected':
console.debug(`[vite] connected.`)
break
case 'update':
notifyListeners('vite:beforeUpdate', payload)
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
if (hasDocument) {
// if this is the first update and there's already an error overlay, it
// means the page opened with existing server compile error and the whole
Expand Down Expand Up @@ -238,10 +238,10 @@ async function handleMessage(payload: HotPayload) {
})
}),
)
notifyListeners('vite:afterUpdate', payload)
await hmrClient.notifyListeners('vite:afterUpdate', payload)
break
case 'custom': {
notifyListeners(payload.event, payload.data)
await hmrClient.notifyListeners(payload.event, payload.data)
if (payload.event === 'vite:ws:disconnect') {
if (hasDocument && !willUnload) {
console.log(`[vite] server connection lost. Polling for restart...`)
Expand All @@ -255,7 +255,7 @@ async function handleMessage(payload: HotPayload) {
break
}
case 'full-reload':
notifyListeners('vite:beforeFullReload', payload)
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
if (hasDocument) {
if (payload.path && payload.path.endsWith('.html')) {
// if html file is edited, only reload the page if the browser is
Expand All @@ -276,11 +276,11 @@ async function handleMessage(payload: HotPayload) {
}
break
case 'prune':
notifyListeners('vite:beforePrune', payload)
await hmrClient.notifyListeners('vite:beforePrune', payload)
await hmrClient.prunePaths(payload.paths)
break
case 'error': {
notifyListeners('vite:error', payload)
await hmrClient.notifyListeners('vite:error', payload)
if (hasDocument) {
const err = payload.err
if (enableOverlay) {
Expand All @@ -302,14 +302,6 @@ async function handleMessage(payload: HotPayload) {
}
}

function notifyListeners<T extends string>(
event: T,
data: InferCustomEventPayload<T>,
): void
function notifyListeners(event: string, data: any): void {
hmrClient.notifyListeners(event, data)
}

const enableOverlay = __HMR_ENABLE_OVERLAY__
const hasDocument = 'document' in globalThis

Expand Down
184 changes: 69 additions & 115 deletions packages/vite/src/module-runner/hmrHandler.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,87 @@
import type { HotPayload } from 'types/hmrPayload'
import { slash, unwrapId } from '../shared/utils'
import { ERR_OUTDATED_OPTIMIZED_DEP } from '../shared/constants'
import { createHMRHandler } from '../shared/hmrHandler'
import type { ModuleRunner } from './runner'

// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example.
export function createHMRHandler(
export function createHMRHandlerForRunner(
runner: ModuleRunner,
): (payload: HotPayload) => Promise<void> {
const queue = new Queue()
return (payload) => queue.enqueue(() => handleHotPayload(runner, payload))
}

export async function handleHotPayload(
runner: ModuleRunner,
payload: HotPayload,
): Promise<void> {
const hmrClient = runner.hmrClient
if (!hmrClient || runner.isClosed()) return
switch (payload.type) {
case 'connected':
hmrClient.logger.debug(`connected.`)
break
case 'update':
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
if (update.type === 'js-update') {
// runner always caches modules by their full path without /@id/ prefix
update.acceptedPath = unwrapId(update.acceptedPath)
update.path = unwrapId(update.path)
return hmrClient.queueUpdate(update)
}
return createHMRHandler(async (payload) => {
const hmrClient = runner.hmrClient
if (!hmrClient || runner.isClosed()) return
switch (payload.type) {
case 'connected':
hmrClient.logger.debug(`connected.`)
break
case 'update':
await hmrClient.notifyListeners('vite:beforeUpdate', payload)
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
if (update.type === 'js-update') {
// runner always caches modules by their full path without /@id/ prefix
update.acceptedPath = unwrapId(update.acceptedPath)
update.path = unwrapId(update.path)
return hmrClient.queueUpdate(update)
}

hmrClient.logger.error('css hmr is not supported in runner mode.')
}),
)
await hmrClient.notifyListeners('vite:afterUpdate', payload)
break
case 'custom': {
await hmrClient.notifyListeners(payload.event, payload.data)
break
}
case 'full-reload': {
const { triggeredBy } = payload
const clearEntrypointUrls = triggeredBy
? getModulesEntrypoints(
runner,
getModulesByFile(runner, slash(triggeredBy)),
)
: findAllEntrypoints(runner)
hmrClient.logger.error('css hmr is not supported in runner mode.')
}),
)
await hmrClient.notifyListeners('vite:afterUpdate', payload)
break
case 'custom': {
await hmrClient.notifyListeners(payload.event, payload.data)
break
}
case 'full-reload': {
const { triggeredBy } = payload
const clearEntrypointUrls = triggeredBy
? getModulesEntrypoints(
runner,
getModulesByFile(runner, slash(triggeredBy)),
)
: findAllEntrypoints(runner)

if (!clearEntrypointUrls.size) break
if (!clearEntrypointUrls.size) break

hmrClient.logger.debug(`program reload`)
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
runner.evaluatedModules.clear()
hmrClient.logger.debug(`program reload`)
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
runner.evaluatedModules.clear()

for (const url of clearEntrypointUrls) {
try {
await runner.import(url)
} catch (err) {
if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) {
hmrClient.logger.error(
`An error happened during full reload\n${err.message}\n${err.stack}`,
)
for (const url of clearEntrypointUrls) {
try {
await runner.import(url)
} catch (err) {
if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) {
hmrClient.logger.error(
`An error happened during full reload\n${err.message}\n${err.stack}`,
)
}
}
}
break
}
case 'prune':
await hmrClient.notifyListeners('vite:beforePrune', payload)
await hmrClient.prunePaths(payload.paths)
break
case 'error': {
await hmrClient.notifyListeners('vite:error', payload)
const err = payload.err
hmrClient.logger.error(
`Internal Server Error\n${err.message}\n${err.stack}`,
)
break
}
case 'ping': // noop
break
default: {
const check: never = payload
return check
}
break
}
case 'prune':
await hmrClient.notifyListeners('vite:beforePrune', payload)
await hmrClient.prunePaths(payload.paths)
break
case 'error': {
await hmrClient.notifyListeners('vite:error', payload)
const err = payload.err
hmrClient.logger.error(
`Internal Server Error\n${err.message}\n${err.stack}`,
)
break
}
case 'ping': // noop
break
default: {
const check: never = payload
return check
}
}
}

class Queue {
private queue: {
promise: () => Promise<void>
resolve: (value?: unknown) => void
reject: (err?: unknown) => void
}[] = []
private pending = false

enqueue(promise: () => Promise<void>) {
return new Promise<any>((resolve, reject) => {
this.queue.push({
promise,
resolve,
reject,
})
this.dequeue()
})
}

dequeue() {
if (this.pending) {
return false
}
const item = this.queue.shift()
if (!item) {
return false
}
this.pending = true
item
.promise()
.then(item.resolve)
.catch(item.reject)
.finally(() => {
this.pending = false
this.dequeue()
})
return true
}
})
}

function getModulesByFile(runner: ModuleRunner, file: string): string[] {
Expand Down
4 changes: 2 additions & 2 deletions packages/vite/src/module-runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
ssrModuleExportsKey,
} from './constants'
import { hmrLogger, silentConsole } from './hmrLogger'
import { createHMRHandler } from './hmrHandler'
import { createHMRHandlerForRunner } from './hmrHandler'
import { enableSourceMapSupport } from './sourcemap/index'
import { ESModulesEvaluator } from './esmEvaluator'

Expand Down Expand Up @@ -83,7 +83,7 @@ export class ModuleRunner {
'HMR is not supported by this runner transport, but `hmr` option was set to true',
)
}
this.transport.connect(createHMRHandler(this))
this.transport.connect(createHMRHandlerForRunner(this))
} else {
this.transport.connect?.()
}
Expand Down
49 changes: 49 additions & 0 deletions packages/vite/src/shared/hmrHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { HotPayload } from 'types/hmrPayload'

// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example.
export function createHMRHandler(
handler: (payload: HotPayload) => Promise<void>,
): (payload: HotPayload) => Promise<void> {
const queue = new Queue()
return (payload) => queue.enqueue(() => handler(payload))
}

class Queue {
private queue: {
promise: () => Promise<void>
resolve: (value?: unknown) => void
reject: (err?: unknown) => void
}[] = []
private pending = false

enqueue(promise: () => Promise<void>): Promise<void> {
return new Promise<any>((resolve, reject) => {
this.queue.push({
promise,
resolve,
reject,
})
this.dequeue()
})
}

dequeue(): boolean {
if (this.pending) {
return false
}
const item = this.queue.shift()
if (!item) {
return false
}
this.pending = true
item
.promise()
.then(item.resolve)
.catch(item.reject)
.finally(() => {
this.pending = false
this.dequeue()
})
return true
}
}
28 changes: 17 additions & 11 deletions playground/hmr/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,27 +935,33 @@ if (!isBuild) {
})

test('deleted file should trigger dispose and prune callbacks', async () => {
browserLogs.length = 0
await page.goto(viteTestUrl)

const parentFile = 'file-delete-restore/parent.js'
const childFile = 'file-delete-restore/child.js'
const originalChildFileCode = readFile(childFile)

// delete the file
editFile(parentFile, (code) =>
code.replace(
"export { value as childValue } from './child'",
"export const childValue = 'not-child'",
),
await untilBrowserLogAfter(
() => {
// delete the file
editFile(parentFile, (code) =>
code.replace(
"export { value as childValue } from './child'",
"export const childValue = 'not-child'",
),
)
removeFile(childFile)
},
[
'file-delete-restore/child.js is disposed',
'file-delete-restore/child.js is pruned',
],
false,
)
const originalChildFileCode = readFile(childFile)
removeFile(childFile)
await untilUpdated(
() => page.textContent('.file-delete-restore'),
'parent:not-child',
)
expect(browserLogs).to.include('file-delete-restore/child.js is disposed')
expect(browserLogs).to.include('file-delete-restore/child.js is pruned')

// restore the file
addFile(childFile, originalChildFileCode)
Expand Down