Skip to content

Commit 531cd7b

Browse files
authoredMay 25, 2022
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
}

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

+33-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
depsFromOptimizedDepInfo,
1010
depsLogString,
1111
discoverProjectDependencies,
12+
extractExportsData,
1213
getOptimizedDepPath,
1314
loadCachedDepOptimizationMetadata,
1415
newDepOptimizationProcessing,
@@ -177,11 +178,28 @@ export function createOptimizedDeps(server: ViteDevServer): OptimizedDeps {
177178

178179
const newData = processingResult.metadata
179180

181+
const needsInteropMismatch = []
182+
for (const dep in metadata.discovered) {
183+
const discoveredDepInfo = metadata.discovered[dep]
184+
const depInfo = newData.optimized[dep]
185+
if (depInfo) {
186+
if (
187+
discoveredDepInfo.needsInterop !== undefined &&
188+
depInfo.needsInterop !== discoveredDepInfo.needsInterop
189+
) {
190+
// This only happens when a discovered dependency has mixed ESM and CJS syntax
191+
// and it hasn't been manually added to optimizeDeps.needsInterop
192+
needsInteropMismatch.push(dep)
193+
}
194+
}
195+
}
196+
180197
// After a re-optimization, if the internal bundled chunks change a full page reload
181198
// is required. If the files are stable, we can avoid the reload that is expensive
182199
// for large applications. Comparing their fileHash we can find out if it is safe to
183200
// keep the current browser state.
184201
const needsReload =
202+
needsInteropMismatch.length > 0 ||
185203
metadata.hash !== newData.hash ||
186204
Object.keys(metadata.optimized).some((dep) => {
187205
return (
@@ -284,6 +302,19 @@ export function createOptimizedDeps(server: ViteDevServer): OptimizedDeps {
284302
timestamp: true
285303
}
286304
)
305+
if (needsInteropMismatch.length > 0) {
306+
config.logger.warn(
307+
`Mixed ESM and CJS detected in ${colors.yellow(
308+
needsInteropMismatch.join(', ')
309+
)}, add ${
310+
needsInteropMismatch.length === 1 ? 'it' : 'them'
311+
} to optimizeDeps.needsInterop to speed up cold start`,
312+
{
313+
timestamp: true
314+
}
315+
)
316+
}
317+
287318
fullReload()
288319
}
289320
}
@@ -378,7 +409,8 @@ export function createOptimizedDeps(server: ViteDevServer): OptimizedDeps {
378409
),
379410
// loading of this pre-bundled dep needs to await for its processing
380411
// promise to be resolved
381-
processing: depOptimizationProcessing.promise
412+
processing: depOptimizationProcessing.promise,
413+
exportsData: extractExportsData(resolved, config)
382414
})
383415

384416
// Debounced rerun, let other missing dependencies be discovered before

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

+55-77
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,6 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
309309
return [url, resolved.id]
310310
}
311311

312-
// Import rewrites, we do them after all the URLs have been resolved
313-
// to help with the discovery of new dependencies. If we need to wait
314-
// for each dependency there could be one reload per import
315-
const importRewrites: (() => Promise<void>)[] = []
316-
317312
for (let index = 0; index < imports.length; index++) {
318313
const {
319314
s: start,
@@ -403,75 +398,66 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
403398
server?.moduleGraph.safeModulesPath.add(fsPathFromUrl(url))
404399

405400
if (url !== specifier) {
406-
importRewrites.push(async () => {
407-
let rewriteDone = false
408-
if (
409-
server?._optimizedDeps &&
410-
isOptimizedDepFile(resolvedId, config) &&
411-
!resolvedId.match(optimizedDepChunkRE)
412-
) {
413-
// for optimized cjs deps, support named imports by rewriting named imports to const assignments.
414-
// internal optimized chunks don't need es interop and are excluded
415-
416-
// The browserHash in resolvedId could be stale in which case there will be a full
417-
// page reload. We could return a 404 in that case but it is safe to return the request
418-
const file = cleanUrl(resolvedId) // Remove ?v={hash}
419-
420-
const needsInterop = await optimizedDepNeedsInterop(
421-
server._optimizedDeps!.metadata,
422-
file
423-
)
424-
425-
if (needsInterop === undefined) {
426-
// Non-entry dynamic imports from dependencies will reach here as there isn't
427-
// optimize info for them, but they don't need es interop. If the request isn't
428-
// a dynamic import, then it is an internal Vite error
429-
if (!file.match(optimizedDepDynamicRE)) {
430-
config.logger.error(
431-
colors.red(
432-
`Vite Error, ${url} optimized info should be defined`
433-
)
434-
)
435-
}
436-
} else if (needsInterop) {
437-
debug(`${url} needs interop`)
438-
if (isDynamicImport) {
439-
// rewrite `import('package')` to expose the default directly
440-
str().overwrite(
441-
expStart,
442-
expEnd,
443-
`import('${url}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))`,
444-
{ contentOnly: true }
401+
let rewriteDone = false
402+
if (
403+
server?._optimizedDeps &&
404+
isOptimizedDepFile(resolvedId, config) &&
405+
!resolvedId.match(optimizedDepChunkRE)
406+
) {
407+
// for optimized cjs deps, support named imports by rewriting named imports to const assignments.
408+
// internal optimized chunks don't need es interop and are excluded
409+
410+
// The browserHash in resolvedId could be stale in which case there will be a full
411+
// page reload. We could return a 404 in that case but it is safe to return the request
412+
const file = cleanUrl(resolvedId) // Remove ?v={hash}
413+
414+
const needsInterop = await optimizedDepNeedsInterop(
415+
server._optimizedDeps!.metadata,
416+
file,
417+
config
418+
)
419+
420+
if (needsInterop === undefined) {
421+
// Non-entry dynamic imports from dependencies will reach here as there isn't
422+
// optimize info for them, but they don't need es interop. If the request isn't
423+
// a dynamic import, then it is an internal Vite error
424+
if (!file.match(optimizedDepDynamicRE)) {
425+
config.logger.error(
426+
colors.red(
427+
`Vite Error, ${url} optimized info should be defined`
445428
)
429+
)
430+
}
431+
} else if (needsInterop) {
432+
debug(`${url} needs interop`)
433+
if (isDynamicImport) {
434+
// rewrite `import('package')` to expose the default directly
435+
str().overwrite(
436+
expStart,
437+
expEnd,
438+
`import('${url}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))`,
439+
{ contentOnly: true }
440+
)
441+
} else {
442+
const exp = source.slice(expStart, expEnd)
443+
const rewritten = transformCjsImport(exp, url, rawUrl, index)
444+
if (rewritten) {
445+
str().overwrite(expStart, expEnd, rewritten, {
446+
contentOnly: true
447+
})
446448
} else {
447-
const exp = source.slice(expStart, expEnd)
448-
const rewritten = transformCjsImport(
449-
exp,
450-
url,
451-
rawUrl,
452-
index
453-
)
454-
if (rewritten) {
455-
str().overwrite(expStart, expEnd, rewritten, {
456-
contentOnly: true
457-
})
458-
} else {
459-
// #1439 export * from '...'
460-
str().overwrite(start, end, url, { contentOnly: true })
461-
}
449+
// #1439 export * from '...'
450+
str().overwrite(start, end, url, { contentOnly: true })
462451
}
463-
rewriteDone = true
464452
}
453+
rewriteDone = true
465454
}
466-
if (!rewriteDone) {
467-
str().overwrite(
468-
start,
469-
end,
470-
isDynamicImport ? `'${url}'` : url,
471-
{ contentOnly: true }
472-
)
473-
}
474-
})
455+
}
456+
if (!rewriteDone) {
457+
str().overwrite(start, end, isDynamicImport ? `'${url}'` : url, {
458+
contentOnly: true
459+
})
460+
}
475461
}
476462

477463
// record for HMR import chain analysis
@@ -636,14 +622,6 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
636622
})
637623
}
638624

639-
// Await for import rewrites that requires dependencies to be pre-bundled to
640-
// know if es interop is needed after starting further transformRequest calls
641-
// This will let Vite process deeper into the user code and find more missing
642-
// dependencies before the next page reload
643-
for (const rewrite of importRewrites) {
644-
await rewrite()
645-
}
646-
647625
if (s) {
648626
return {
649627
code: s.toString(),

‎playground/ssr-vue/__tests__/ssr-vue.spec.ts

+2
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ test('css', async () => {
117117
expect(await getColor('h1')).toBe('green')
118118
expect(await getColor('.jsx')).toBe('blue')
119119
} else {
120+
// During dev, the CSS is loaded from async chunk and we may have to wait
121+
// when the test runs concurrently.
120122
await untilUpdated(() => getColor('h1'), 'green')
121123
await untilUpdated(() => getColor('.jsx'), 'blue')
122124
}

0 commit comments

Comments
 (0)
Please sign in to comment.