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

feat: non-blocking needs interop #7568

Merged
merged 17 commits into from May 25, 2022
Merged
3 changes: 2 additions & 1 deletion packages/vite/src/node/index.ts
Expand Up @@ -41,7 +41,8 @@ export type {
DepOptimizationResult,
DepOptimizationProcessing,
OptimizedDepInfo,
OptimizedDeps
OptimizedDeps,
ExportsData
} from './optimizer'
export type { Plugin } from './plugin'
export type { PackageCache, PackageData } from './packages'
Expand Down
173 changes: 116 additions & 57 deletions packages/vite/src/node/optimizer/index.ts
Expand Up @@ -32,6 +32,7 @@ export type ExportsData = ReturnType<typeof parse> & {
// es-module-lexer has a facade detection but isn't always accurate for our
// use case when the module has default export
hasReExports?: true
jsxLoader: boolean
patak-dev marked this conversation as resolved.
Show resolved Hide resolved
}

export interface OptimizedDeps {
Expand Down Expand Up @@ -62,6 +63,11 @@ export interface DepOptimizationOptions {
* cannot be globs).
*/
exclude?: string[]
/**
* Force ESM interop when importing for these dependencies. Some legacy
* packages advertise themselves as ESM but use `require` internally
*/
needsInterop?: string[]
bluwy marked this conversation as resolved.
Show resolved Hide resolved
/**
* Options to pass to esbuild during the dep scanning and optimization
*
Expand Down Expand Up @@ -131,6 +137,11 @@ export interface OptimizedDepInfo {
* but the bundles may not yet be saved to disk
*/
processing?: Promise<void>
/**
* ExportData cache, discovered deps will parse the src entry to get exports
* data used both to define if interop is needed and when pre-bundling
*/
exportsData?: Promise<ExportsData>
}

export interface DepOptimizationMetadata {
Expand Down Expand Up @@ -294,12 +305,13 @@ export async function discoverProjectDependencies(
)
const discovered: Record<string, OptimizedDepInfo> = {}
for (const id in deps) {
const entry = deps[id]
const src = deps[id]
discovered[id] = {
id,
file: getOptimizedDepPath(id, config),
src: entry,
browserHash: browserHash
src,
browserHash: browserHash,
exportsData: extractExportsData(src, config)
}
}
return discovered
Expand Down Expand Up @@ -389,52 +401,24 @@ export async function runOptimizeDeps(
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}

await init
for (const id in depsInfo) {
const flatId = flattenId(id)
const filePath = (flatIdDeps[flatId] = depsInfo[id].src!)
let exportsData: ExportsData
if (config.optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) {
// For custom supported extensions, build the entry file to transform it into JS,
// and then parse with es-module-lexer. Note that the `bundle` option is not `true`,
// so only the entry file is being transformed.
const result = await build({
...esbuildOptions,
plugins,
entryPoints: [filePath],
write: false,
format: 'esm'
})
exportsData = parse(result.outputFiles[0].text) as ExportsData
} else {
const entryContent = fs.readFileSync(filePath, 'utf-8')
try {
exportsData = parse(entryContent) as ExportsData
} catch {
debug(
`Unable to parse dependency: ${id}. Trying again with a JSX transform.`
)
const transformed = await transformWithEsbuild(entryContent, filePath, {
loader: 'jsx'
})
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
// This is useful for packages such as Gatsby.
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader
}
exportsData = parse(transformed.code) as ExportsData
}
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+\*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
const src = depsInfo[id].src!
const exportsData = await (depsInfo[id].exportsData ??
extractExportsData(src, config))
if (exportsData.jsxLoader) {
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
// This is useful for packages such as Gatsby.
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader
}
}

const flatId = flattenId(id)
flatIdDeps[flatId] = src
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData

depsInfo[id].needsInterop ??= needsInterop(id, idToExports[id], config)
}

const define: Record<string, string> = {
Expand Down Expand Up @@ -479,16 +463,27 @@ export async function runOptimizeDeps(
for (const id in depsInfo) {
const output = esbuildOutputFromId(meta.outputs, id, processingCacheDir)

const { exportsData, ...info } = depsInfo[id]
addOptimizedDepInfo(metadata, 'optimized', {
...depsInfo[id],
needsInterop: needsInterop(id, idToExports[id], output),
...info,
// We only need to hash the output.imports in to check for stability, but adding the hash
// and file path gives us a unique hash that may be useful for other things in the future
fileHash: getHash(
metadata.hash + depsInfo[id].file + JSON.stringify(output.imports)
),
browserHash: metadata.browserHash
})

// After bundling we have more information and can warn the user about legacy packages
// that require manual configuration
if (
!depsInfo[id].needsInterop &&
hasExportsMismatch(idToExports[id], output)
) {
config.logger.warn(
`${id} needs ES Interop, add it to the optimizeDeps.needsInterop config array`
)
}
}

for (const o of Object.keys(meta.outputs)) {
Expand Down Expand Up @@ -738,6 +733,55 @@ function esbuildOutputFromId(
]
}

export async function extractExportsData(
filePath: string,
config: ResolvedConfig
): Promise<ExportsData> {
await init
let exportsData: ExportsData

const esbuildOptions = config.optimizeDeps?.esbuildOptions ?? {}
if (config.optimizeDeps.extensions?.some((ext) => filePath.endsWith(ext))) {
// For custom supported extensions, build the entry file to transform it into JS,
// and then parse with es-module-lexer. Note that the `bundle` option is not `true`,
// so only the entry file is being transformed.
const result = await build({
...esbuildOptions,
entryPoints: [filePath],
write: false,
format: 'esm'
})
exportsData = parse(result.outputFiles[0].text) as ExportsData
} else {
const entryContent = fs.readFileSync(filePath, 'utf-8')
try {
exportsData = parse(entryContent) as ExportsData
} catch {
debug(
`Unable to parse: ${filePath}.\n Trying again with a JSX transform.`
)
const transformed = await transformWithEsbuild(entryContent, filePath, {
loader: 'jsx'
})
// Ensure that optimization won't fail by defaulting '.js' to the JSX parser.
// This is useful for packages such as Gatsby.
esbuildOptions.loader = {
'.js': 'jsx',
...esbuildOptions.loader
}
exportsData = parse(transformed.code) as ExportsData
exportsData.jsxLoader = true
}
for (const { ss, se } of exportsData[0]) {
const exp = entryContent.slice(ss, se)
if (/export\s+\*\s+from/.test(exp)) {
exportsData.hasReExports = true
}
}
}
return exportsData
}

// https://github.com/vitejs/vite/issues/1724#issuecomment-767619642
// a list of modules that pretends to be ESM but still uses `require`.
// this causes esbuild to wrap them as CJS even when its entry appears to be ESM.
Expand All @@ -746,20 +790,32 @@ const KNOWN_INTEROP_IDS = new Set(['moment'])
function needsInterop(
id: string,
exportsData: ExportsData,
output: { exports: string[] }
config: ResolvedConfig
): boolean {
if (KNOWN_INTEROP_IDS.has(id)) {
if (
config.optimizeDeps?.needsInterop?.includes(id) ||
KNOWN_INTEROP_IDS.has(id)
) {
return true
}
const [imports, exports] = exportsData
// entry has no ESM syntax - likely CJS or UMD
if (!exports.length && !imports.length) {
return true
}
// ESM module
return false
}

function hasExportsMismatch(
exportsData: ExportsData,
output: { exports: string[] }
): boolean {
const [, exports] = exportsData

// if a peer dependency used require() on a ESM dependency, esbuild turns the
// ESM dependency's entry chunk into a single default export... detect
// such cases by checking exports mismatch, and force interop.
// ESM dependency's entry chunk into a single default export... we can detect
// such cases by checking exports mismatch, and warn the user.
const generatedExports: string[] = output.exports

if (
Expand Down Expand Up @@ -853,14 +909,17 @@ function findOptimizedDepInfoInRecord(

export async function optimizedDepNeedsInterop(
metadata: DepOptimizationMetadata,
file: string
file: string,
config: ResolvedConfig
): Promise<boolean | undefined> {
const depInfo = optimizedDepInfoFromFile(metadata, file)

if (!depInfo) return undefined

// Wait until the dependency has been pre-bundled
await depInfo.processing

if (depInfo?.src && depInfo.needsInterop === undefined) {
depInfo.exportsData ??= extractExportsData(depInfo.src, config)
depInfo.needsInterop = needsInterop(
depInfo.id,
await depInfo.exportsData,
config
)
}
return depInfo?.needsInterop
}
4 changes: 3 additions & 1 deletion packages/vite/src/node/optimizer/registerMissing.ts
Expand Up @@ -11,6 +11,7 @@ import {
addOptimizedDepInfo,
discoverProjectDependencies,
depsLogString,
extractExportsData,
debuggerViteDeps as debug
} from '.'
import type {
Expand Down Expand Up @@ -379,7 +380,8 @@ export function createOptimizedDeps(server: ViteDevServer): OptimizedDeps {
),
// loading of this pre-bundled dep needs to await for its processing
// promise to be resolved
processing: depOptimizationProcessing.promise
processing: depOptimizationProcessing.promise,
exportsData: extractExportsData(resolved, config)
})

// Debounced rerun, let other missing dependencies be discovered before
Expand Down