Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(runtime): runtime HMR affects only imported files #15898

Merged
merged 15 commits into from Feb 22, 2024
Merged
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')