diff --git a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts index 57e67c2b47a166..04c978130f256c 100644 --- a/packages/vite/src/node/optimizer/esbuildDepPlugin.ts +++ b/packages/vite/src/node/optimizer/esbuildDepPlugin.ts @@ -12,7 +12,7 @@ import { moduleListContains, normalizePath } from '../utils' -import { browserExternalId } from '../plugins/resolve' +import { browserExternalId, optionalPeerDepId } from '../plugins/resolve' import type { ExportsData } from '.' const externalWithConversionNamespace = @@ -93,6 +93,12 @@ export function esbuildDepPlugin( namespace: 'browser-external' } } + if (resolved.startsWith(optionalPeerDepId)) { + return { + path: resolved, + namespace: 'optional-peer-dep' + } + } if (ssr && isBuiltin(resolved)) { return } @@ -279,6 +285,22 @@ module.exports = Object.create(new Proxy({}, { } ) + build.onLoad( + { filter: /.*/, namespace: 'optional-peer-dep' }, + ({ path }) => { + if (config.isProduction) { + return { + contents: 'module.exports = {}' + } + } else { + const [, peerDep, parentDep] = path.split(':') + return { + contents: `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)` + } + } + } + ) + // yarn 2 pnp compat if (isRunningWithYarnPnp) { build.onResolve( diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 13ac18ae384371..f33105d265a27a 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -30,6 +30,7 @@ import { isPossibleTsOutput, isTsRequest, isWindows, + lookupFile, nestedResolveFrom, normalizePath, resolveFrom, @@ -44,6 +45,8 @@ import { loadPackageData, resolvePackageData } from '../packages' // special id for paths marked with browser: false // https://github.com/defunctzombie/package-browser-field-spec#ignore-a-module export const browserExternalId = '__vite-browser-external' +// special id for packages that are optional peer deps +export const optionalPeerDepId = '__vite-optional-peer-dep' const isDebug = process.env.DEBUG const debug = createDebugger('vite:resolve-details', { @@ -365,6 +368,14 @@ export default new Proxy({}, { })` } } + if (id.startsWith(optionalPeerDepId)) { + if (isProduction) { + return `export default {}` + } else { + const [, peerDep, parentDep] = id.split(':') + return `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)` + } + } } } } @@ -618,6 +629,30 @@ export function tryNodeResolve( })! if (!pkg) { + // if import can't be found, check if it's an optional peer dep. + // if so, we can resolve to a special id that errors only when imported. + if ( + basedir !== root && // root has no peer dep + !isBuiltin(id) && + !id.includes('\0') && + bareImportRE.test(id) + ) { + // find package.json with `name` as main + const mainPackageJson = lookupFile(basedir, ['package.json'], { + predicate: (content) => !!JSON.parse(content).name + }) + if (mainPackageJson) { + const mainPkg = JSON.parse(mainPackageJson) + if ( + mainPkg.peerDependencies?.[id] && + mainPkg.peerDependenciesMeta?.[id]?.optional + ) { + return { + id: `${optionalPeerDepId}:${id}:${mainPkg.name}` + } + } + } + } return } diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 85f85adf3cf550..0f681ec8f7378f 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -390,6 +390,7 @@ export function isDefined(value: T | undefined | null): value is T { interface LookupFileOptions { pathOnly?: boolean rootDir?: string + predicate?: (file: string) => boolean } export function lookupFile( @@ -400,7 +401,12 @@ export function lookupFile( for (const format of formats) { const fullPath = path.join(dir, format) if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { - return options?.pathOnly ? fullPath : fs.readFileSync(fullPath, 'utf-8') + const result = options?.pathOnly + ? fullPath + : fs.readFileSync(fullPath, 'utf-8') + if (!options?.predicate || options.predicate(result)) { + return result + } } } const parentDir = path.dirname(dir) diff --git a/playground/optimize-deps/__tests__/optimize-deps.spec.ts b/playground/optimize-deps/__tests__/optimize-deps.spec.ts index df0c080f09ab47..fd717b0401499b 100644 --- a/playground/optimize-deps/__tests__/optimize-deps.spec.ts +++ b/playground/optimize-deps/__tests__/optimize-deps.spec.ts @@ -88,6 +88,19 @@ test('dep with dynamic import', async () => { ) }) +test('dep with optional peer dep', async () => { + expect(await page.textContent('.dep-with-optional-peer-dep')).toMatch( + `[success]` + ) + if (isServe) { + expect(browserErrors.map((error) => error.message)).toEqual( + expect.arrayContaining([ + 'Could not resolve "foobar" imported by "dep-with-optional-peer-dep". Is it installed?' + ]) + ) + } +}) + test('dep with css import', async () => { expect(await getColor('.dep-linked-include')).toBe('red') }) diff --git a/playground/optimize-deps/dep-with-optional-peer-dep/index.js b/playground/optimize-deps/dep-with-optional-peer-dep/index.js new file mode 100644 index 00000000000000..bce89ca18f3ad7 --- /dev/null +++ b/playground/optimize-deps/dep-with-optional-peer-dep/index.js @@ -0,0 +1,7 @@ +export function callItself() { + return '[success]' +} + +export async function callPeerDep() { + return await import('foobar') +} diff --git a/playground/optimize-deps/dep-with-optional-peer-dep/package.json b/playground/optimize-deps/dep-with-optional-peer-dep/package.json new file mode 100644 index 00000000000000..bf43db6b7919d9 --- /dev/null +++ b/playground/optimize-deps/dep-with-optional-peer-dep/package.json @@ -0,0 +1,15 @@ +{ + "name": "dep-with-optional-peer-dep", + "private": true, + "version": "0.0.0", + "main": "index.js", + "type": "module", + "peerDependencies": { + "foobar": "0.0.0" + }, + "peerDependenciesMeta": { + "foobar": { + "optional": true + } + } +} diff --git a/playground/optimize-deps/index.html b/playground/optimize-deps/index.html index 7b0c43e82fdcbc..fe507e9ba568f4 100644 --- a/playground/optimize-deps/index.html +++ b/playground/optimize-deps/index.html @@ -59,6 +59,9 @@

Import from dependency with dynamic import

+

Import from dependency with optional peer dep

+
+

Dep w/ special file format supported via plugins

@@ -152,6 +155,13 @@

Flatten Id

text('.reused-variable-names', reusedName) + +