Skip to content

Commit

Permalink
fix(runtime): runtime HMR affects only imported files (#15898)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Feb 22, 2024
1 parent 37af8a7 commit 57463fc
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 13 deletions.
10 changes: 6 additions & 4 deletions packages/vite/src/node/server/hmr.ts
Expand Up @@ -166,6 +166,7 @@ export async function handleHMRUpdate(
hot.send({
type: 'full-reload',
path: '*',
triggeredBy: path.resolve(config.root, file),
})
return
}
Expand Down Expand Up @@ -272,6 +273,7 @@ export function updateModules(
)
hot.send({
type: 'full-reload',
triggeredBy: path.resolve(config.root, file),
})
return
}
Expand All @@ -295,7 +297,7 @@ export function updateModules(
function populateSSRImporters(
module: ModuleNode,
timestamp: number,
seen: Set<ModuleNode>,
seen: Set<ModuleNode> = new Set(),
) {
module.ssrImportedModules.forEach((importer) => {
if (seen.has(importer)) {
Expand All @@ -313,9 +315,9 @@ function populateSSRImporters(
}

function getSSRInvalidatedImporters(module: ModuleNode) {
return [
...populateSSRImporters(module, module.lastHMRTimestamp, new Set()),
].map((m) => m.file!)
return [...populateSSRImporters(module, module.lastHMRTimestamp)].map(
(m) => m.file!,
)
}

export async function handleFileAddUnlink(
Expand Down
24 changes: 17 additions & 7 deletions packages/vite/src/node/ssr/runtime/hmrHandler.ts
Expand Up @@ -43,18 +43,28 @@ export async function handleHMRPayload(
await hmrClient.notifyListeners(payload.event, payload.data)
break
}
case 'full-reload':
case 'full-reload': {
const { triggeredBy } = payload
const clearEntrypoints = triggeredBy
? [...runtime.entrypoints].filter((entrypoint) =>
runtime.moduleCache.isImported({
importedId: triggeredBy,
importedBy: entrypoint,
}),
)
: [...runtime.entrypoints]

if (!clearEntrypoints.length) break

hmrClient.logger.debug(`[vite] program reload`)
await hmrClient.notifyListeners('vite:beforeFullReload', payload)
Array.from(runtime.moduleCache.keys()).forEach((id) => {
if (!id.includes('node_modules')) {
runtime.moduleCache.deleteByModuleId(id)
}
})
for (const id of runtime.entrypoints) {
runtime.moduleCache.clear()

for (const id of clearEntrypoints) {
await runtime.executeUrl(id)
}
break
}
case 'prune':
await hmrClient.notifyListeners('vite:beforePrune', payload)
hmrClient.prunePaths(payload.paths)
Expand Down
51 changes: 51 additions & 0 deletions packages/vite/src/node/ssr/runtime/moduleCache.ts
Expand Up @@ -65,6 +65,57 @@ export class ModuleCacheMap extends Map<string, ModuleCache> {
return this.deleteByModuleId(this.normalize(fsPath))
}

invalidate(id: string): void {
const module = this.get(id)
module.evaluated = false
module.meta = undefined
module.map = undefined
module.promise = undefined
module.exports = undefined
// remove imports in case they are changed,
// don't remove the importers because otherwise it will be empty after evaluation
// this can create a bug when file was removed but it still triggers full-reload
// we are fine with the bug for now because it's not a common case
module.imports?.clear()
}

isImported(
{
importedId,
importedBy,
}: {
importedId: string
importedBy: string
},
seen = new Set<string>(),
): boolean {
importedId = this.normalize(importedId)
importedBy = this.normalize(importedBy)

if (importedBy === importedId) return true

if (seen.has(importedId)) return false
seen.add(importedId)

const fileModule = this.getByModuleId(importedId)
const importers = fileModule?.importers

if (!importers) return false

if (importers.has(importedBy)) return true

for (const importer of importers) {
if (
this.isImported({
importedBy: importedBy,
importedId: importer,
})
)
return true
}
return false
}

/**
* Invalidate modules that dependent on the given modules, up to the main entry
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/vite/src/node/ssr/runtime/runtime.ts
Expand Up @@ -72,7 +72,7 @@ export class ViteRuntime {
: options.hmr.logger || console,
options.hmr.connection,
({ acceptedPath, ssrInvalidates }) => {
this.moduleCache.delete(acceptedPath)
this.moduleCache.invalidate(acceptedPath)
if (ssrInvalidates) {
this.invalidateFiles(ssrInvalidates)
}
Expand Down Expand Up @@ -140,7 +140,7 @@ export class ViteRuntime {
files.forEach((file) => {
const ids = this.fileToIdMap.get(file)
if (ids) {
ids.forEach((id) => this.moduleCache.deleteByModuleId(id))
ids.forEach((id) => this.moduleCache.invalidate(id))
}
})
}
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/types/hmrPayload.d.ts
Expand Up @@ -36,6 +36,8 @@ export interface PrunePayload {
export interface FullReloadPayload {
type: 'full-reload'
path?: string
/** @internal */
triggeredBy?: string
}

export interface CustomPayload {
Expand Down
49 changes: 49 additions & 0 deletions playground/hmr-ssr/__tests__/hmr.spec.ts
Expand Up @@ -506,6 +506,55 @@ describe('acceptExports', () => {
)
})
})

describe("doesn't reload if files not in the the entrypoint importers chain is changed", async () => {
const testFile = 'non-tested/index.js'

beforeAll(async () => {
clientLogs.length = 0
// so it's in the module graph
await server.transformRequest(testFile, { ssr: true })
await server.transformRequest('non-tested/dep.js', { ssr: true })
})

test('does not full reload', async () => {
editFile(
testFile,
(code) => code + '\n\nexport const query5 = "query5"',
)
const start = Date.now()
// for 2 seconds check that there is no log about the file being reloaded
while (Date.now() - start < 2000) {
if (
clientLogs.some(
(log) =>
log.match(PROGRAM_RELOAD) ||
log.includes('non-tested/index.js'),
)
) {
throw new Error('File was reloaded')
}
await new Promise((r) => setTimeout(r, 100))
}
}, 5_000)

test('does not update', async () => {
editFile('non-tested/dep.js', (code) => code + '//comment')
const start = Date.now()
// for 2 seconds check that there is no log about the file being reloaded
while (Date.now() - start < 2000) {
if (
clientLogs.some(
(log) =>
log.match(PROGRAM_RELOAD) || log.includes('non-tested/dep.js'),
)
) {
throw new Error('File was updated')
}
await new Promise((r) => setTimeout(r, 100))
}
}, 5_000)
})
})

test('accepts itself when imported for side effects only (no bindings imported)', async () => {
Expand Down
3 changes: 3 additions & 0 deletions playground/hmr-ssr/non-tested/dep.js
@@ -0,0 +1,3 @@
export const test = 'true'

import.meta.hot.accept()
9 changes: 9 additions & 0 deletions playground/hmr-ssr/non-tested/index.js
@@ -0,0 +1,9 @@
import { test } from './dep.js'

function main() {
test()
}

main()

import.meta.hot.accept('./dep.js')

0 comments on commit 57463fc

Please sign in to comment.