diff --git a/packages/vite/src/node/__tests__/external.spec.ts b/packages/vite/src/node/__tests__/external.spec.ts index a4c78519fd91ef..5e8220f3535589 100644 --- a/packages/vite/src/node/__tests__/external.spec.ts +++ b/packages/vite/src/node/__tests__/external.spec.ts @@ -7,12 +7,12 @@ import { PartialEnvironment } from '../baseEnvironment' describe('createIsConfiguredAsExternal', () => { test('default', async () => { const isExternal = await createIsExternal() - expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(false) + expect(await isExternal('@vitejs/cjs-ssr-dep')).toBe(false) }) test('force external', async () => { const isExternal = await createIsExternal(true) - expect(isExternal('@vitejs/cjs-ssr-dep')).toBe(true) + expect(await isExternal('@vitejs/cjs-ssr-dep')).toBe(true) }) }) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 42e07c67ee9235..6ce114a80cf14f 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -1953,32 +1953,34 @@ async function bundleConfigFile( name: 'externalize-deps', setup(build) { const packageCache = new Map() - const resolveByViteResolver = ( + const resolveByViteResolver = async ( id: string, importer: string, isRequire: boolean, ) => { - return tryNodeResolve(id, importer, { - root: path.dirname(fileName), - isBuild: true, - isProduction: true, - preferRelative: false, - tryIndex: true, - mainFields: [], - conditions: [ - 'node', - ...(isModuleSyncConditionEnabled ? ['module-sync'] : []), - ], - externalConditions: [], - external: [], - noExternal: [], - dedupe: [], - extensions: configDefaults.resolve.extensions, - preserveSymlinks: false, - packageCache, - isRequire, - builtins: nodeLikeBuiltins, - })?.id + return ( + await tryNodeResolve(id, importer, { + root: path.dirname(fileName), + isBuild: true, + isProduction: true, + preferRelative: false, + tryIndex: true, + mainFields: [], + conditions: [ + 'node', + ...(isModuleSyncConditionEnabled ? ['module-sync'] : []), + ], + externalConditions: [], + external: [], + noExternal: [], + dedupe: [], + extensions: configDefaults.resolve.extensions, + preserveSymlinks: false, + packageCache, + isRequire, + builtins: nodeLikeBuiltins, + }) + )?.id } // externalize bare imports @@ -2003,16 +2005,16 @@ async function bundleConfigFile( const isImport = isESM || kind === 'dynamic-import' let idFsPath: string | undefined try { - idFsPath = resolveByViteResolver(id, importer, !isImport) + idFsPath = await resolveByViteResolver(id, importer, !isImport) } catch (e) { if (!isImport) { let canResolveWithImport = false try { - canResolveWithImport = !!resolveByViteResolver( + canResolveWithImport = !!(await resolveByViteResolver( id, importer, false, - ) + )) } catch {} if (canResolveWithImport) { throw new Error( diff --git a/packages/vite/src/node/external.ts b/packages/vite/src/node/external.ts index 6da3fefa840a2f..29ac78e1f9629f 100644 --- a/packages/vite/src/node/external.ts +++ b/packages/vite/src/node/external.ts @@ -16,25 +16,25 @@ const debug = createDebugger('vite:external') const isExternalCache = new WeakMap< Environment, - (id: string, importer?: string) => boolean + (id: string, importer?: string) => Promise >() -export function shouldExternalize( +export async function shouldExternalize( environment: Environment, id: string, importer: string | undefined, -): boolean { +): Promise { let isExternal = isExternalCache.get(environment) if (!isExternal) { isExternal = createIsExternal(environment) isExternalCache.set(environment, isExternal) } - return isExternal(id, importer) + return await isExternal(id, importer) } export function createIsConfiguredAsExternal( environment: PartialEnvironment, -): (id: string, importer?: string) => boolean { +): (id: string, importer?: string) => Promise { const { config } = environment const { root, resolve } = config const { external, noExternal } = resolve @@ -53,16 +53,16 @@ export function createIsConfiguredAsExternal( conditions: targetConditions, } - const isExternalizable = ( + const isExternalizable = async ( id: string, importer: string | undefined, configuredAsExternal: boolean, - ): boolean => { + ): Promise => { if (!bareImportRE.test(id) || id.includes('\0')) { return false } try { - const resolved = tryNodeResolve( + const resolved = await tryNodeResolve( id, // Skip passing importer in build to avoid externalizing non-hoisted dependencies // unresolvable from root (which would be unresolvable from output bundles also) @@ -91,7 +91,7 @@ export function createIsConfiguredAsExternal( // Returns true if it is configured as external, false if it is filtered // by noExternal and undefined if it isn't affected by the explicit config - return (id: string, importer?: string) => { + return async (id: string, importer?: string) => { if ( // If this id is defined as external, force it as external // Note that individual package entries are allowed in `external` @@ -120,18 +120,18 @@ export function createIsConfiguredAsExternal( } // If external is true, all will be externalized by default, regardless if // it's a linked package - return isExternalizable(id, importer, external === true) + return await isExternalizable(id, importer, external === true) } } function createIsExternal( environment: Environment, -): (id: string, importer?: string) => boolean { +): (id: string, importer?: string) => Promise { const processedIds = new Map() const isConfiguredAsExternal = createIsConfiguredAsExternal(environment) - return (id: string, importer?: string) => { + return async (id: string, importer?: string) => { if (processedIds.has(id)) { return processedIds.get(id)! } @@ -139,7 +139,7 @@ function createIsExternal( if (id[0] !== '.' && !path.isAbsolute(id)) { isExternal = isBuiltin(environment.config.resolve.builtins, id) || - isConfiguredAsExternal(id, importer) + (await isConfiguredAsExternal(id, importer)) } processedIds.set(id, isExternal) return isExternal diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index db698b8c3b722c..54e030e55cc203 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -1330,11 +1330,11 @@ function getDepHash(environment: Environment): { } } -function getOptimizedBrowserHash( +export function getOptimizedBrowserHash( hash: string, deps: Record, timestamp = '', -) { +): string { return getHash(hash + JSON.stringify(deps) + timestamp) } diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts index 02a3bce8d837f1..a8d2243338ccb7 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -15,6 +15,7 @@ import { depsLogString, discoverProjectDependencies, extractExportsData, + getOptimizedBrowserHash, getOptimizedDepPath, initDepsOptimizerMetadata, loadCachedDepOptimizationMetadata, @@ -237,6 +238,20 @@ export function createDepsOptimizer( const knownDeps = prepareKnownDeps() startNextDiscoveredBatch() + // Ensure consistent browserHash between in-memory and persisted metadata. + // By setting it eagerly here (before scanProcessing resolves), both the + // current server and any subsequent server loading _metadata.json will + // produce the same browserHash for these deps, avoiding mismatches during + // mid-load restarts. + metadata.browserHash = getOptimizedBrowserHash( + metadata.hash, + depsFromOptimizedDepInfo(knownDeps), + ) + + for (const dep of Object.keys(metadata.discovered)) { + metadata.discovered[dep].browserHash = metadata.browserHash + } + // For dev, we run the scanner and the first optimization // run on the background optimizationResult = runOptimizeDeps(environment, knownDeps) diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 075426d34036ec..a85a2739eedcdd 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -331,6 +331,8 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { depsOptimizer && moduleListContains(depsOptimizer.options.exclude, url) ) { + // Wait for scanning to complete to ensure stable browserHash and metadata + // This prevents inconsistent hashes between in-memory and persisted metadata await depsOptimizer.scanProcessing // if the dependency encountered in the optimized file was excluded from the optimization @@ -520,7 +522,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { } // skip ssr externals and builtins if (ssr && !matchAlias(specifier)) { - if (shouldExternalize(environment, specifier, importer)) { + if (await shouldExternalize(environment, specifier, importer)) { return } if (isBuiltin(environment.config.resolve.builtins, specifier)) { diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 303e5cc0042365..28103dc766b86b 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -214,7 +214,11 @@ export function resolvePlugin( } } - let res: string | PartialResolvedId | undefined + let res: + | string + | PartialResolvedId + | undefined + | Promise // resolve pre-bundled deps requests, these could be resolved by // tryFileResolve or /fs/ resolution but these files may not yet @@ -233,7 +237,7 @@ export function resolvePlugin( // always return here even if res doesn't exist since /@fs/ is explicit // if the file doesn't exist it should be a 404. debug?.(`[@fs] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return await ensureVersionQuery(res, id, options, depsOptimizer) } // URL @@ -246,7 +250,7 @@ export function resolvePlugin( const fsPath = path.resolve(root, id.slice(1)) if ((res = tryFsResolve(fsPath, options))) { debug?.(`[url] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return await ensureVersionQuery(res, id, options, depsOptimizer) } } @@ -268,6 +272,9 @@ export function resolvePlugin( // Optimized files could not yet exist in disk, resolve to the full path // Inject the current browserHash version if the path doesn't have one if (!options.isBuild && !DEP_VERSION_RE.test(normalizedFsPath)) { + // Wait for scanning to complete to ensure stable browserHash + // This prevents inconsistent hashes between in-memory and persisted metadata + await depsOptimizer.scanProcessing const browserHash = optimizedDepInfoFromFile( depsOptimizer.metadata, normalizedFsPath, @@ -281,13 +288,18 @@ export function resolvePlugin( if ( options.mainFields.includes('browser') && - (res = tryResolveBrowserMapping(fsPath, importer, options, true)) + (res = await tryResolveBrowserMapping( + fsPath, + importer, + options, + true, + )) ) { return res } if ((res = tryFsResolve(fsPath, options))) { - res = ensureVersionQuery(res, id, options, depsOptimizer) + res = await ensureVersionQuery(res, id, options, depsOptimizer) debug?.(`[relative] ${colors.cyan(id)} -> ${colors.dim(res)}`) if (!options.idOnly && !options.scan && options.isBuild) { @@ -318,7 +330,7 @@ export function resolvePlugin( const fsPath = path.resolve(basedir, id) if ((res = tryFsResolve(fsPath, options))) { debug?.(`[drive-relative] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return await ensureVersionQuery(res, id, options, depsOptimizer) } } @@ -328,7 +340,7 @@ export function resolvePlugin( (res = tryFsResolve(id, options)) ) { debug?.(`[fs] ${colors.cyan(id)} -> ${colors.dim(res)}`) - return ensureVersionQuery(res, id, options, depsOptimizer) + return await ensureVersionQuery(res, id, options, depsOptimizer) } // external @@ -348,7 +360,7 @@ export function resolvePlugin( options.externalize && options.isBuild && currentEnvironmentOptions.consumer === 'server' && - shouldExternalize(this.environment, id, importer) + (await shouldExternalize(this.environment, id, importer)) if ( !external && asSrc && @@ -367,7 +379,7 @@ export function resolvePlugin( if ( options.mainFields.includes('browser') && - (res = tryResolveBrowserMapping( + (res = await tryResolveBrowserMapping( id, importer, options, @@ -378,16 +390,17 @@ export function resolvePlugin( return res } - if ( - (res = tryNodeResolve( - id, - importer, - options, - depsOptimizer, - external, - )) - ) { - return res + res = tryNodeResolve(id, importer, options, depsOptimizer, external) + if (res) { + // Handle both sync and async returns + if (res instanceof Promise) { + const resolvedRes = await res + if (resolvedRes) { + return resolvedRes + } + } else { + return res + } } // built-ins @@ -528,18 +541,21 @@ function resolveSubpathImports( return importsPath + postfix } -function ensureVersionQuery( +async function ensureVersionQuery( resolved: string, id: string, options: InternalResolveOptions, depsOptimizer?: DepsOptimizer, -): string { +): Promise { if ( !options.isBuild && !options.scan && depsOptimizer && !(resolved === normalizedClientEntry || resolved === normalizedEnvEntry) ) { + // Wait for scanning to complete to ensure stable browserHash + // This prevents inconsistent hashes between in-memory and persisted metadata + await depsOptimizer.scanProcessing // Ensure that direct imports of node_modules have the same version query // as if they would have been imported through a bare import // Use the original id to do the check as the resolved id may be the real @@ -693,13 +709,13 @@ function tryCleanFsResolve( } } -export function tryNodeResolve( +export async function tryNodeResolve( id: string, importer: string | null | undefined, options: InternalResolveOptions, depsOptimizer?: DepsOptimizer, externalize?: boolean, -): PartialResolvedId | undefined { +): Promise { const { root, dedupe, isBuild, preserveSymlinks, packageCache } = options // check for deep import, e.g. "my-lib/foo" @@ -833,6 +849,20 @@ export function tryNodeResolve( // can cache it without re-validation, but only do so for known js types. // otherwise we may introduce duplicated modules for externalized files // from pre-bundled deps. + + // If we need to access browserHash, we must wait for scanning to complete + // to ensure stable browserHash and prevent inconsistent hashes between + // in-memory and persisted metadata + if (depsOptimizer.scanProcessing) { + await depsOptimizer.scanProcessing + + const versionHash = depsOptimizer.metadata.browserHash + if (versionHash && isJsType && resolved) { + resolved = injectQuery(resolved, `v=${versionHash}`) + } + return processResult({ id: resolved! }) + } + const versionHash = depsOptimizer.metadata.browserHash if (versionHash && isJsType) { resolved = injectQuery(resolved, `v=${versionHash}`) @@ -854,8 +884,8 @@ export async function tryOptimizedResolve( preserveSymlinks?: boolean, packageCache?: PackageCache, ): Promise { - // TODO: we need to wait until scanning is done here as this function - // is used in the preAliasPlugin to decide if an aliased dep is optimized, + // Wait for scanning to complete to ensure stable browserHash and metadata + // This function is used in the preAliasPlugin to decide if an aliased dep is optimized, // and avoid replacing the bare import with the resolved path. // We should be able to remove this in the future await depsOptimizer.scanProcessing @@ -1099,7 +1129,7 @@ function resolveDeepImport( } } -function tryResolveBrowserMapping( +async function tryResolveBrowserMapping( id: string, importer: string | undefined, options: InternalResolveOptions, @@ -1116,12 +1146,14 @@ function tryResolveBrowserMapping( if (browserMappedPath) { if ( (res = bareImportRE.test(browserMappedPath) - ? tryNodeResolve( - browserMappedPath, - importer, - options, - undefined, - undefined, + ? ( + (await tryNodeResolve( + browserMappedPath, + importer, + options, + undefined, + undefined, + )) as PartialResolvedId | undefined )?.id : tryFsResolve(path.join(pkg.dir, browserMappedPath), options)) ) { diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index fabeaaa1e716d8..2fc42d8fdee17d 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -48,7 +48,7 @@ export async function fetchModule( const { externalConditions, dedupe, preserveSymlinks } = environment.config.resolve - const resolved = tryNodeResolve(url, importer, { + const resolved = await tryNodeResolve(url, importer, { mainFields: ['main'], conditions: externalConditions, externalConditions, diff --git a/playground/optimize-deps/__tests__/optimize-deps.spec.ts b/playground/optimize-deps/__tests__/optimize-deps.spec.ts index ccbd6883d33586..9da5991a4178a3 100644 --- a/playground/optimize-deps/__tests__/optimize-deps.spec.ts +++ b/playground/optimize-deps/__tests__/optimize-deps.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' import { + browser, browserErrors, browserLogs, getColor, @@ -8,6 +9,7 @@ import { page, readDepOptimizationMetadata, serverLogs, + viteServer, viteTestUrl, } from '~utils' @@ -354,3 +356,32 @@ test('dependency with external sub-dependencies', async () => { .poll(() => page.textContent('.dep-cjs-with-external-deps-node-builtin')) .toBe('foo bar') }) + +test.runIf(isServe)( + 'the metadata written by the dep optimizer should match the metadata in memory', + async () => { + await viteServer.waitForRequestsIdle() + const metadata = readDepOptimizationMetadata() + + let page = await browser.newPage() + const response = page.waitForResponse(/\/cjs\.js/) + await page.goto(viteTestUrl) + + const content = await (await response).text() + const reactBrowserHash = content.match( + /from "\/node_modules\/\.vite\/deps\/react\.js\?v=([^"&]*)"/, + )?.[1] + expect(reactBrowserHash).toBe(metadata.browserHash) + + await viteServer.restart() + + page = await browser.newPage() + const responseAfterRestart = page.waitForResponse(/cjs\.js/) + await page.goto(viteTestUrl) + const contentAfterRestart = await (await responseAfterRestart).text() + const reactBrowserHashAfterRestart = contentAfterRestart.match( + /from "\/node_modules\/\.vite\/deps\/react\.js\?v=([^"&]*)"/, + )?.[1] + expect(reactBrowserHashAfterRestart).toBe(metadata.browserHash) + }, +)