Skip to content

Commit 531cd7b

Browse files
authored
feat: non-blocking needs interop (#7568)
1 parent 689adc0 commit 531cd7b

File tree

5 files changed

+221
-173
lines changed

5 files changed

+221
-173
lines changed

packages/vite/src/node/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export type {
3636
DepOptimizationResult,
3737
DepOptimizationProcessing,
3838
OptimizedDepInfo,
39-
OptimizedDeps
39+
OptimizedDeps,
40+
ExportsData
4041
} from './optimizer'
4142
export type { Plugin } from './plugin'
4243
export type { PackageCache, PackageData } from './packages'

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

+129-94
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export type ExportsData = ReturnType<typeof parse> & {
3434
// es-module-lexer has a facade detection but isn't always accurate for our
3535
// use case when the module has default export
3636
hasReExports?: true
37+
// hint if the dep requires loading as jsx
38+
jsxLoader?: true
3739
}
3840

3941
export interface OptimizedDeps {
@@ -64,6 +66,12 @@ export interface DepOptimizationOptions {
6466
* cannot be globs).
6567
*/
6668
exclude?: string[]
69+
/**
70+
* Force ESM interop when importing for these dependencies. Some legacy
71+
* packages advertise themselves as ESM but use `require` internally
72+
* @experimental
73+
*/
74+
needsInterop?: string[]
6775
/**
6876
* Options to pass to esbuild during the dep scanning and optimization
6977
*
@@ -134,6 +142,11 @@ export interface OptimizedDepInfo {
134142
* but the bundles may not yet be saved to disk
135143
*/
136144
processing?: Promise<void>
145+
/**
146+
* ExportData cache, discovered deps will parse the src entry to get exports
147+
* data used both to define if interop is needed and when pre-bundling
148+
*/
149+
exportsData?: Promise<ExportsData>
137150
}
138151

139152
export interface DepOptimizationMetadata {
@@ -297,12 +310,13 @@ export async function discoverProjectDependencies(
297310
)
298311
const discovered: Record<string, OptimizedDepInfo> = {}
299312
for (const id in deps) {
300-
const entry = deps[id]
313+
const src = deps[id]
301314
discovered[id] = {
302315
id,
303316
file: getOptimizedDepPath(id, config),
304-
src: entry,
305-
browserHash: browserHash
317+
src,
318+
browserHash: browserHash,
319+
exportsData: extractExportsData(src, config)
306320
}
307321
}
308322
return discovered
@@ -368,17 +382,24 @@ export async function runOptimizeDeps(
368382

369383
const qualifiedIds = Object.keys(depsInfo)
370384

371-
if (!qualifiedIds.length) {
372-
return {
373-
metadata,
374-
commit() {
375-
// Write metadata file, delete `deps` folder and rename the `processing` folder to `deps`
376-
return commitProcessingDepsCacheSync()
377-
},
378-
cancel
385+
const processingResult: DepOptimizationResult = {
386+
metadata,
387+
async commit() {
388+
// Write metadata file, delete `deps` folder and rename the `processing` folder to `deps`
389+
// Processing is done, we can now replace the depsCacheDir with processingCacheDir
390+
// Rewire the file paths from the temporal processing dir to the final deps cache dir
391+
await removeDir(depsCacheDir)
392+
await renameDir(processingCacheDir, depsCacheDir)
393+
},
394+
cancel() {
395+
fs.rmSync(processingCacheDir, { recursive: true, force: true })
379396
}
380397
}
381398

399+
if (!qualifiedIds.length) {
400+
return processingResult
401+
}
402+
382403
// esbuild generates nested directory output with lowest common ancestor base
383404
// this is unpredictable and makes it difficult to analyze entry / output
384405
// mapping. So what we do here is:
@@ -392,51 +413,20 @@ export async function runOptimizeDeps(
392413
const { plugins = [], ...esbuildOptions } =
393414
config.optimizeDeps?.esbuildOptions ?? {}
394415

395-
await init
396416
for (const id in depsInfo) {
397-
const flatId = flattenId(id)
398-
const filePath = (flatIdDeps[flatId] = depsInfo[id].src!)
399-
let exportsData: ExportsData
400-
if (config.optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) {
401-
// For custom supported extensions, build the entry file to transform it into JS,
402-
// and then parse with es-module-lexer. Note that the `bundle` option is not `true`,
403-
// so only the entry file is being transformed.
404-
const result = await build({
405-
...esbuildOptions,
406-
plugins,
407-
entryPoints: [filePath],
408-
write: false,
409-
format: 'esm'
410-
})
411-
exportsData = parse(result.outputFiles[0].text) as ExportsData
412-
} else {
413-
const entryContent = fs.readFileSync(filePath, 'utf-8')
414-
try {
415-
exportsData = parse(entryContent) as ExportsData
416-
} catch {
417-
const loader = esbuildOptions.loader?.[path.extname(filePath)] || 'jsx'
418-
debug(
419-
`Unable to parse dependency: ${id}. Trying again with a ${loader} transform.`
420-
)
421-
const transformed = await transformWithEsbuild(entryContent, filePath, {
422-
loader
423-
})
424-
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
425-
// This is useful for packages such as Gatsby.
426-
esbuildOptions.loader = {
427-
'.js': 'jsx',
428-
...esbuildOptions.loader
429-
}
430-
exportsData = parse(transformed.code) as ExportsData
431-
}
432-
for (const { ss, se } of exportsData[0]) {
433-
const exp = entryContent.slice(ss, se)
434-
if (/export\s+\*\s+from/.test(exp)) {
435-
exportsData.hasReExports = true
436-
}
417+
const src = depsInfo[id].src!
418+
const exportsData = await (depsInfo[id].exportsData ??
419+
extractExportsData(src, config))
420+
if (exportsData.jsxLoader) {
421+
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
422+
// This is useful for packages such as Gatsby.
423+
esbuildOptions.loader = {
424+
'.js': 'jsx',
425+
...esbuildOptions.loader
437426
}
438427
}
439-
428+
const flatId = flattenId(id)
429+
flatIdDeps[flatId] = src
440430
idToExports[id] = exportsData
441431
flatIdToExports[flatId] = exportsData
442432
}
@@ -483,15 +473,18 @@ export async function runOptimizeDeps(
483473
for (const id in depsInfo) {
484474
const output = esbuildOutputFromId(meta.outputs, id, processingCacheDir)
485475

476+
const { exportsData, ...info } = depsInfo[id]
486477
addOptimizedDepInfo(metadata, 'optimized', {
487-
...depsInfo[id],
488-
needsInterop: needsInterop(id, idToExports[id], output),
478+
...info,
489479
// We only need to hash the output.imports in to check for stability, but adding the hash
490480
// and file path gives us a unique hash that may be useful for other things in the future
491481
fileHash: getHash(
492482
metadata.hash + depsInfo[id].file + JSON.stringify(output.imports)
493483
),
494-
browserHash: metadata.browserHash
484+
browserHash: metadata.browserHash,
485+
// After bundling we have more information and can warn the user about legacy packages
486+
// that require manual configuration
487+
needsInterop: needsInterop(config, id, idToExports[id], output)
495488
})
496489
}
497490

@@ -522,25 +515,7 @@ export async function runOptimizeDeps(
522515

523516
debug(`deps bundled in ${(performance.now() - start).toFixed(2)}ms`)
524517

525-
return {
526-
metadata,
527-
commit() {
528-
// Write metadata file, delete `deps` folder and rename the new `processing` folder to `deps` in sync
529-
return commitProcessingDepsCacheSync()
530-
},
531-
cancel
532-
}
533-
534-
async function commitProcessingDepsCacheSync() {
535-
// Processing is done, we can now replace the depsCacheDir with processingCacheDir
536-
// Rewire the file paths from the temporal processing dir to the final deps cache dir
537-
await removeDir(depsCacheDir)
538-
await renameDir(processingCacheDir, depsCacheDir)
539-
}
540-
541-
function cancel() {
542-
fs.rmSync(processingCacheDir, { recursive: true, force: true })
543-
}
518+
return processingResult
544519
}
545520

546521
export async function findKnownImports(
@@ -735,17 +710,71 @@ function esbuildOutputFromId(
735710
]
736711
}
737712

713+
export async function extractExportsData(
714+
filePath: string,
715+
config: ResolvedConfig
716+
): Promise<ExportsData> {
717+
await init
718+
let exportsData: ExportsData
719+
720+
const esbuildOptions = config.optimizeDeps?.esbuildOptions ?? {}
721+
if (config.optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) {
722+
// For custom supported extensions, build the entry file to transform it into JS,
723+
// and then parse with es-module-lexer. Note that the `bundle` option is not `true`,
724+
// so only the entry file is being transformed.
725+
const result = await build({
726+
...esbuildOptions,
727+
entryPoints: [filePath],
728+
write: false,
729+
format: 'esm'
730+
})
731+
exportsData = parse(result.outputFiles[0].text) as ExportsData
732+
} else {
733+
const entryContent = fs.readFileSync(filePath, 'utf-8')
734+
try {
735+
exportsData = parse(entryContent) as ExportsData
736+
} catch {
737+
const loader = esbuildOptions.loader?.[path.extname(filePath)] || 'jsx'
738+
debug(
739+
`Unable to parse: ${filePath}.\n Trying again with a ${loader} transform.`
740+
)
741+
const transformed = await transformWithEsbuild(entryContent, filePath, {
742+
loader
743+
})
744+
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
745+
// This is useful for packages such as Gatsby.
746+
esbuildOptions.loader = {
747+
'.js': 'jsx',
748+
...esbuildOptions.loader
749+
}
750+
exportsData = parse(transformed.code) as ExportsData
751+
exportsData.jsxLoader = true
752+
}
753+
for (const { ss, se } of exportsData[0]) {
754+
const exp = entryContent.slice(ss, se)
755+
if (/export\s+\*\s+from/.test(exp)) {
756+
exportsData.hasReExports = true
757+
}
758+
}
759+
}
760+
return exportsData
761+
}
762+
738763
// https://github.com/vitejs/vite/issues/1724#issuecomment-767619642
739764
// a list of modules that pretends to be ESM but still uses `require`.
740765
// this causes esbuild to wrap them as CJS even when its entry appears to be ESM.
741766
const KNOWN_INTEROP_IDS = new Set(['moment'])
742767

743768
function needsInterop(
769+
config: ResolvedConfig,
744770
id: string,
745771
exportsData: ExportsData,
746-
output: { exports: string[] }
772+
output?: { exports: string[] }
747773
): boolean {
748-
if (KNOWN_INTEROP_IDS.has(id)) {
774+
if (
775+
config.optimizeDeps?.needsInterop?.includes(id) ||
776+
KNOWN_INTEROP_IDS.has(id)
777+
) {
749778
return true
750779
}
751780
const [imports, exports] = exportsData
@@ -754,16 +783,19 @@ function needsInterop(
754783
return true
755784
}
756785

757-
// if a peer dependency used require() on a ESM dependency, esbuild turns the
758-
// ESM dependency's entry chunk into a single default export... detect
759-
// such cases by checking exports mismatch, and force interop.
760-
const generatedExports: string[] = output.exports
761-
762-
if (
763-
!generatedExports ||
764-
(isSingleDefaultExport(generatedExports) && !isSingleDefaultExport(exports))
765-
) {
766-
return true
786+
if (output) {
787+
// if a peer dependency used require() on a ESM dependency, esbuild turns the
788+
// ESM dependency's entry chunk into a single default export... detect
789+
// such cases by checking exports mismatch, and force interop.
790+
const generatedExports: string[] = output.exports
791+
792+
if (
793+
!generatedExports ||
794+
(isSingleDefaultExport(generatedExports) &&
795+
!isSingleDefaultExport(exports))
796+
) {
797+
return true
798+
}
767799
}
768800
return false
769801
}
@@ -846,14 +878,17 @@ function findOptimizedDepInfoInRecord(
846878

847879
export async function optimizedDepNeedsInterop(
848880
metadata: DepOptimizationMetadata,
849-
file: string
881+
file: string,
882+
config: ResolvedConfig
850883
): Promise<boolean | undefined> {
851884
const depInfo = optimizedDepInfoFromFile(metadata, file)
852-
853-
if (!depInfo) return undefined
854-
855-
// Wait until the dependency has been pre-bundled
856-
await depInfo.processing
857-
885+
if (depInfo?.src && depInfo.needsInterop === undefined) {
886+
depInfo.exportsData ??= extractExportsData(depInfo.src, config)
887+
depInfo.needsInterop = needsInterop(
888+
config,
889+
depInfo.id,
890+
await depInfo.exportsData
891+
)
892+
}
858893
return depInfo?.needsInterop
859894
}

0 commit comments

Comments
 (0)