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(resolve): support "fallback array" in package exports field #10504

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vite/package.json
Expand Up @@ -110,7 +110,7 @@
"postcss-import": "^15.0.0",
"postcss-load-config": "^4.0.1",
"postcss-modules": "^5.0.0",
"resolve.exports": "^1.1.0",
"resolve.exports": "npm:@alloc/resolve.exports@^1.1.0",
"sirv": "^2.0.2",
"source-map-js": "^1.0.2",
"source-map-support": "^0.5.21",
Expand Down
10 changes: 3 additions & 7 deletions packages/vite/src/node/config.ts
Expand Up @@ -48,11 +48,7 @@ import {
DEFAULT_MAIN_FIELDS,
ENV_ENTRY
} from './constants'
import type {
InternalResolveOptions,
InternalResolveOptionsWithOverrideConditions,
ResolveOptions
} from './plugins/resolve'
import type { InternalResolveOptions, ResolveOptions } from './plugins/resolve'
import { resolvePlugin, tryNodeResolve } from './plugins/resolve'
import type { LogLevel, Logger } from './logger'
import { createLogger } from './logger'
Expand Down Expand Up @@ -958,7 +954,7 @@ async function bundleConfigFile(
{
name: 'externalize-deps',
setup(build) {
const options: InternalResolveOptionsWithOverrideConditions = {
const options: InternalResolveOptions = {
root: path.dirname(fileName),
isBuild: true,
isProduction: true,
Expand All @@ -968,7 +964,7 @@ async function bundleConfigFile(
mainFields: [],
browserField: false,
conditions: [],
overrideConditions: ['node'],
overrideConditions: ['node', 'require'],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes a bug introduced in #10683

Some packages use "require" instead of "default" for CJS entry (eg: vitest/config)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this bug was introduced (and now fixed) by this PR :)

The overrideConditions array is respected by @alloc/resolve.exports for all conditions (including import and require) except for default of course. So we have to define import or require explicitly in the overrideConditions option.

dedupe: [],
extensions: DEFAULT_EXTENSIONS,
preserveSymlinks: false
Expand Down
186 changes: 89 additions & 97 deletions packages/vite/src/node/plugins/resolve.ts
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import colors from 'picocolors'
import type { PartialResolvedId } from 'rollup'
import { resolve as _resolveExports } from 'resolve.exports'
import { resolveExports } from 'resolve.exports'
import { hasESMSyntax } from 'mlly'
import type { Plugin } from '../plugin'
import {
Expand Down Expand Up @@ -107,6 +107,7 @@ export interface InternalResolveOptions extends Required<ResolveOptions> {
shouldExternalize?: (id: string) => boolean | undefined
// Check this resolve is called from `hookNodeResolve` in SSR
isHookNodeResolve?: boolean
overrideConditions?: string[]
}

export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin {
Expand Down Expand Up @@ -579,21 +580,12 @@ function tryResolveFile(
}
}

export type InternalResolveOptionsWithOverrideConditions =
InternalResolveOptions & {
/**
* @deprecated In future, `conditions` will work like this.
* @internal
*/
overrideConditions?: string[]
}

export const idToPkgMap = new Map<string, PackageData>()

export function tryNodeResolve(
id: string,
importer: string | null | undefined,
options: InternalResolveOptionsWithOverrideConditions,
options: InternalResolveOptions,
targetWeb: boolean,
depsOptimizer?: DepsOptimizer,
ssr?: boolean,
Expand Down Expand Up @@ -923,29 +915,29 @@ export function resolvePackageEntry(
return cached
}
try {
let entryPoint: string | undefined | void
let entryPoints: string[] = []
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new resolveExports returns an array of possible module paths. It prefers to return an empty array, rather than throw an error. Vite should only throw if none of the paths in the array are found.


// resolve exports field with highest priority
// using https://github.com/lukeed/resolve.exports
// the exports field takes highest priority as described in
// https://nodejs.org/api/packages.html#package-entry-points
if (data.exports) {
entryPoint = resolveExports(data, '.', options, targetWeb)
}

// if exports resolved to .mjs, still resolve other fields.
// This is because .mjs files can technically import .cjs files which would
// make them invalid for pure ESM environments - so if other module/browser
// fields are present, prioritize those instead.
if (
targetWeb &&
options.browserField &&
(!entryPoint || entryPoint.endsWith('.mjs'))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for .mjs check anymore, since exports resolution is now strict.

) {
entryPoints = resolveExports(
data,
'.',
options,
getInlineConditions(options, targetWeb),
options.overrideConditions
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When overrideConditions is defined, only conditions in that array will be allowed. To enforce that, we must pass it as the last argument of resolveExports.

)
if (!entryPoints.length) {
packageEntryFailure(id)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from #8484, and it makes resolution more strict when an exports field is found. If the imported module isn't specified in exports map, avoid checking other fields (e.g. main or browser).

}
} else if (targetWeb && options.browserField) {
// check browser field
// https://github.com/defunctzombie/package-browser-field-spec
const browserEntry =
typeof data.browser === 'string'
? data.browser
: isObject(data.browser) && data.browser['.']

if (browserEntry) {
// check if the package also has a "module" field.
if (
Expand All @@ -968,34 +960,34 @@ export function resolvePackageEntry(
const content = fs.readFileSync(resolvedBrowserEntry, 'utf-8')
if (hasESMSyntax(content)) {
// likely ESM, prefer browser
entryPoint = browserEntry
entryPoints[0] = browserEntry
} else {
// non-ESM, UMD or IIFE or CJS(!!! e.g. firebase 7.x), prefer module
entryPoint = data.module
entryPoints[0] = data.module
}
}
} else {
entryPoint = browserEntry
entryPoints[0] = browserEntry
}
}
}

if (!entryPoint || entryPoint.endsWith('.mjs')) {
if (!entryPoints[0]) {
for (const field of options.mainFields) {
if (field === 'browser') continue // already checked above
if (typeof data[field] === 'string') {
entryPoint = data[field]
entryPoints[0] = data[field]
break
}
}
entryPoints[0] ||= data.main
}
entryPoint ||= data.main

// try default entry when entry is not define
// https://nodejs.org/api/modules.html#all-together
const entryPoints = entryPoint
? [entryPoint]
: ['index.js', 'index.json', 'index.node']
if (!entryPoints[0]) {
entryPoints = ['index.js', 'index.json', 'index.node']
}

for (let entry of entryPoints) {
// make sure we don't get scripts when looking for sass
Expand Down Expand Up @@ -1040,52 +1032,41 @@ function packageEntryFailure(id: string, details?: string) {
)
}

const conditionalConditions = new Set(['production', 'development', 'module'])

function resolveExports(
pkg: PackageData['data'],
key: string,
options: InternalResolveOptionsWithOverrideConditions,
/**
* This generates conditions that aren't inferred by `resolveExports`
* from the `options` object.
*/
function getInlineConditions(
options: InternalResolveOptions,
targetWeb: boolean
) {
const overrideConditions = options.overrideConditions
? new Set(options.overrideConditions)
: undefined
const inlineConditions: string[] = []

const conditions = []
if (
(!overrideConditions || overrideConditions.has('production')) &&
options.isProduction
) {
conditions.push('production')
}
if (
(!overrideConditions || overrideConditions.has('development')) &&
!options.isProduction
) {
conditions.push('development')
}
if (
(!overrideConditions || overrideConditions.has('module')) &&
!options.isRequire
) {
conditions.push('module')
const conditions: readonly string[] =
options.overrideConditions || options.conditions

if (targetWeb) {
if (!conditions.includes('node')) {
inlineConditions.push('browser')
}
} else if (!conditions.includes('browser')) {
inlineConditions.push('node')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the browser option of lukeed's resolve.exports module (see here), but we might want to replace the ['node'] expression with [] to avoid the assumption that non-browser environment is using Node.js

}
if (options.overrideConditions) {
conditions.push(
...options.overrideConditions.filter((condition) =>
conditionalConditions.has(condition)
)
)
} else if (options.conditions.length > 0) {
conditions.push(...options.conditions)

// The "module" condition is no longer recommended, but some older
// packages may still use it.
if (!options.isRequire && !conditions.includes('require')) {
inlineConditions.push('module')
}

return _resolveExports(pkg, key, {
browser: targetWeb && !conditions.includes('node'),
require: options.isRequire && !conditions.includes('import'),
conditions
// The "overrideConditions" array can add arbitrary conditions.
options.overrideConditions?.forEach((condition) => {
if (!inlineConditions.includes(condition)) {
inlineConditions.push(condition)
}
})

return inlineConditions
}

function resolveDeepImport(
Expand All @@ -1105,47 +1086,58 @@ function resolveDeepImport(
return cache
}

let relativeId: string | undefined | void = id
const { exports: exportsField, browser: browserField } = data
const { file, postfix } = splitFileAndPostfix(id)

// map relative based on exports data
let possibleFiles: string[] | undefined
if (exportsField) {
if (isObject(exportsField) && !Array.isArray(exportsField)) {
// resolve without postfix (see #7098)
const { file, postfix } = splitFileAndPostfix(relativeId)
const exportsId = resolveExports(data, file, options, targetWeb)
if (exportsId !== undefined) {
relativeId = exportsId + postfix
// map relative based on exports data
possibleFiles = resolveExports(
data,
file,
options,
getInlineConditions(options, targetWeb),
options.overrideConditions
)
if (postfix) {
if (possibleFiles.length) {
possibleFiles = possibleFiles.map((f) => f + postfix)
} else {
relativeId = undefined
possibleFiles = resolveExports(
data,
file + postfix,
options,
getInlineConditions(options, targetWeb),
options.overrideConditions
)
}
} else {
// not exposed
relativeId = undefined
}
if (!relativeId) {
if (!possibleFiles.length) {
throw new Error(
`Package subpath '${relativeId}' is not defined by "exports" in ` +
`Package subpath '${file}' is not defined by "exports" in ` +
`${path.join(dir, 'package.json')}.`
)
}
} else if (targetWeb && options.browserField && isObject(browserField)) {
// resolve without postfix (see #7098)
const { file, postfix } = splitFileAndPostfix(relativeId)
const mapped = mapWithBrowserField(file, browserField)
if (mapped) {
relativeId = mapped + postfix
possibleFiles = [mapped + postfix]
} else if (mapped === false) {
return (webResolvedImports[id] = browserExternalId)
}
}

if (relativeId) {
const resolved = tryFsResolve(
path.join(dir, relativeId),
options,
!exportsField, // try index only if no exports field
targetWeb
possibleFiles ||= [id]
if (possibleFiles[0]) {
let resolved: string | undefined
possibleFiles.some(
(file) =>
(resolved = tryFsResolve(
path.join(dir, file),
options,
!exportsField, // try index only if no exports field
targetWeb
))
)
if (resolved) {
isDebug &&
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.