Skip to content

Commit a1d485c

Browse files
committed
fix(optimize-deps): ensure consistent browserHash between in-memory and persisted metadata
When the dev server restarts mid–asset waterfall and loads cached `_metadata.json`, inconsistent `browserHash` values can cause duplicate instances of the same dependency to be loaded (e.g. multiple React copies). This patch sets the `browserHash` eagerly in memory before resolving `scanProcessing`, ensuring both the in-memory metadata and the persisted metadata use the same value.
1 parent 3a92bc7 commit a1d485c

File tree

9 files changed

+158
-76
lines changed

9 files changed

+158
-76
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { PartialEnvironment } from '../baseEnvironment'
77
describe('createIsConfiguredAsExternal', () => {
88
test('default', async () => {
99
const isExternal = await createIsExternal()
10-
expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(false)
10+
expect(await isExternal('@vitejs/cjs-ssr-dep')).toBe(false)
1111
})
1212

1313
test('force external', async () => {
1414
const isExternal = await createIsExternal(true)
15-
expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(true)
15+
expect(await isExternal('@vitejs/cjs-ssr-dep')).toBe(true)
1616
})
1717
})
1818

packages/vite/src/node/config.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,32 +1953,34 @@ async function bundleConfigFile(
19531953
name: 'externalize-deps',
19541954
setup(build) {
19551955
const packageCache = new Map()
1956-
const resolveByViteResolver = (
1956+
const resolveByViteResolver = async (
19571957
id: string,
19581958
importer: string,
19591959
isRequire: boolean,
19601960
) => {
1961-
return tryNodeResolve(id, importer, {
1962-
root: path.dirname(fileName),
1963-
isBuild: true,
1964-
isProduction: true,
1965-
preferRelative: false,
1966-
tryIndex: true,
1967-
mainFields: [],
1968-
conditions: [
1969-
'node',
1970-
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
1971-
],
1972-
externalConditions: [],
1973-
external: [],
1974-
noExternal: [],
1975-
dedupe: [],
1976-
extensions: configDefaults.resolve.extensions,
1977-
preserveSymlinks: false,
1978-
packageCache,
1979-
isRequire,
1980-
builtins: nodeLikeBuiltins,
1981-
})?.id
1961+
return (
1962+
await tryNodeResolve(id, importer, {
1963+
root: path.dirname(fileName),
1964+
isBuild: true,
1965+
isProduction: true,
1966+
preferRelative: false,
1967+
tryIndex: true,
1968+
mainFields: [],
1969+
conditions: [
1970+
'node',
1971+
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
1972+
],
1973+
externalConditions: [],
1974+
external: [],
1975+
noExternal: [],
1976+
dedupe: [],
1977+
extensions: configDefaults.resolve.extensions,
1978+
preserveSymlinks: false,
1979+
packageCache,
1980+
isRequire,
1981+
builtins: nodeLikeBuiltins,
1982+
})
1983+
)?.id
19821984
}
19831985

19841986
// externalize bare imports
@@ -2003,16 +2005,16 @@ async function bundleConfigFile(
20032005
const isImport = isESM || kind === 'dynamic-import'
20042006
let idFsPath: string | undefined
20052007
try {
2006-
idFsPath = resolveByViteResolver(id, importer, !isImport)
2008+
idFsPath = await resolveByViteResolver(id, importer, !isImport)
20072009
} catch (e) {
20082010
if (!isImport) {
20092011
let canResolveWithImport = false
20102012
try {
2011-
canResolveWithImport = !!resolveByViteResolver(
2013+
canResolveWithImport = !!(await resolveByViteResolver(
20122014
id,
20132015
importer,
20142016
false,
2015-
)
2017+
))
20162018
} catch {}
20172019
if (canResolveWithImport) {
20182020
throw new Error(

packages/vite/src/node/external.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,25 @@ const debug = createDebugger('vite:external')
1616

1717
const isExternalCache = new WeakMap<
1818
Environment,
19-
(id: string, importer?: string) => boolean
19+
(id: string, importer?: string) => Promise<boolean>
2020
>()
2121

22-
export function shouldExternalize(
22+
export async function shouldExternalize(
2323
environment: Environment,
2424
id: string,
2525
importer: string | undefined,
26-
): boolean {
26+
): Promise<boolean> {
2727
let isExternal = isExternalCache.get(environment)
2828
if (!isExternal) {
2929
isExternal = createIsExternal(environment)
3030
isExternalCache.set(environment, isExternal)
3131
}
32-
return isExternal(id, importer)
32+
return await isExternal(id, importer)
3333
}
3434

3535
export function createIsConfiguredAsExternal(
3636
environment: PartialEnvironment,
37-
): (id: string, importer?: string) => boolean {
37+
): (id: string, importer?: string) => Promise<boolean> {
3838
const { config } = environment
3939
const { root, resolve } = config
4040
const { external, noExternal } = resolve
@@ -53,16 +53,16 @@ export function createIsConfiguredAsExternal(
5353
conditions: targetConditions,
5454
}
5555

56-
const isExternalizable = (
56+
const isExternalizable = async (
5757
id: string,
5858
importer: string | undefined,
5959
configuredAsExternal: boolean,
60-
): boolean => {
60+
): Promise<boolean> => {
6161
if (!bareImportRE.test(id) || id.includes('\0')) {
6262
return false
6363
}
6464
try {
65-
const resolved = tryNodeResolve(
65+
const resolved = await tryNodeResolve(
6666
id,
6767
// Skip passing importer in build to avoid externalizing non-hoisted dependencies
6868
// unresolvable from root (which would be unresolvable from output bundles also)
@@ -91,7 +91,7 @@ export function createIsConfiguredAsExternal(
9191

9292
// Returns true if it is configured as external, false if it is filtered
9393
// by noExternal and undefined if it isn't affected by the explicit config
94-
return (id: string, importer?: string) => {
94+
return async (id: string, importer?: string) => {
9595
if (
9696
// If this id is defined as external, force it as external
9797
// Note that individual package entries are allowed in `external`
@@ -120,26 +120,26 @@ export function createIsConfiguredAsExternal(
120120
}
121121
// If external is true, all will be externalized by default, regardless if
122122
// it's a linked package
123-
return isExternalizable(id, importer, external === true)
123+
return await isExternalizable(id, importer, external === true)
124124
}
125125
}
126126

127127
function createIsExternal(
128128
environment: Environment,
129-
): (id: string, importer?: string) => boolean {
129+
): (id: string, importer?: string) => Promise<boolean> {
130130
const processedIds = new Map<string, boolean>()
131131

132132
const isConfiguredAsExternal = createIsConfiguredAsExternal(environment)
133133

134-
return (id: string, importer?: string) => {
134+
return async (id: string, importer?: string) => {
135135
if (processedIds.has(id)) {
136136
return processedIds.get(id)!
137137
}
138138
let isExternal = false
139139
if (id[0] !== '.' && !path.isAbsolute(id)) {
140140
isExternal =
141141
isBuiltin(environment.config.resolve.builtins, id) ||
142-
isConfiguredAsExternal(id, importer)
142+
(await isConfiguredAsExternal(id, importer))
143143
}
144144
processedIds.set(id, isExternal)
145145
return isExternal

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,11 +1330,11 @@ function getDepHash(environment: Environment): {
13301330
}
13311331
}
13321332

1333-
function getOptimizedBrowserHash(
1333+
export function getOptimizedBrowserHash(
13341334
hash: string,
13351335
deps: Record<string, string>,
13361336
timestamp = '',
1337-
) {
1337+
): string {
13381338
return getHash(hash + JSON.stringify(deps) + timestamp)
13391339
}
13401340

packages/vite/src/node/optimizer/optimizer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
depsLogString,
1616
discoverProjectDependencies,
1717
extractExportsData,
18+
getOptimizedBrowserHash,
1819
getOptimizedDepPath,
1920
initDepsOptimizerMetadata,
2021
loadCachedDepOptimizationMetadata,
@@ -237,6 +238,20 @@ export function createDepsOptimizer(
237238
const knownDeps = prepareKnownDeps()
238239
startNextDiscoveredBatch()
239240

241+
// Ensure consistent browserHash between in-memory and persisted metadata.
242+
// By setting it eagerly here (before scanProcessing resolves), both the
243+
// current server and any subsequent server loading _metadata.json will
244+
// produce the same browserHash for these deps, avoiding mismatches during
245+
// mid-load restarts.
246+
metadata.browserHash = getOptimizedBrowserHash(
247+
metadata.hash,
248+
depsFromOptimizedDepInfo(knownDeps),
249+
)
250+
251+
for (const dep of Object.keys(metadata.discovered)) {
252+
metadata.discovered[dep].browserHash = metadata.browserHash
253+
}
254+
240255
// For dev, we run the scanner and the first optimization
241256
// run on the background
242257
optimizationResult = runOptimizeDeps(environment, knownDeps)

packages/vite/src/node/plugins/importAnalysis.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
331331
depsOptimizer &&
332332
moduleListContains(depsOptimizer.options.exclude, url)
333333
) {
334+
// Wait for scanning to complete to ensure stable browserHash and metadata
335+
// This prevents inconsistent hashes between in-memory and persisted metadata
334336
await depsOptimizer.scanProcessing
335337

336338
// if the dependency encountered in the optimized file was excluded from the optimization
@@ -520,7 +522,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
520522
}
521523
// skip ssr externals and builtins
522524
if (ssr && !matchAlias(specifier)) {
523-
if (shouldExternalize(environment, specifier, importer)) {
525+
if (await shouldExternalize(environment, specifier, importer)) {
524526
return
525527
}
526528
if (isBuiltin(environment.config.resolve.builtins, specifier)) {

0 commit comments

Comments
 (0)