Skip to content

Commit eec3886

Browse files
authored
fix: handle resolve optional peer deps (#9321)
1 parent 2f468bb commit eec3886

File tree

9 files changed

+131
-2
lines changed

9 files changed

+131
-2
lines changed

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

+23-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
moduleListContains,
1313
normalizePath
1414
} from '../utils'
15-
import { browserExternalId } from '../plugins/resolve'
15+
import { browserExternalId, optionalPeerDepId } from '../plugins/resolve'
1616
import type { ExportsData } from '.'
1717

1818
const externalWithConversionNamespace =
@@ -93,6 +93,12 @@ export function esbuildDepPlugin(
9393
namespace: 'browser-external'
9494
}
9595
}
96+
if (resolved.startsWith(optionalPeerDepId)) {
97+
return {
98+
path: resolved,
99+
namespace: 'optional-peer-dep'
100+
}
101+
}
96102
if (ssr && isBuiltin(resolved)) {
97103
return
98104
}
@@ -279,6 +285,22 @@ module.exports = Object.create(new Proxy({}, {
279285
}
280286
)
281287

288+
build.onLoad(
289+
{ filter: /.*/, namespace: 'optional-peer-dep' },
290+
({ path }) => {
291+
if (config.isProduction) {
292+
return {
293+
contents: 'module.exports = {}'
294+
}
295+
} else {
296+
const [, peerDep, parentDep] = path.split(':')
297+
return {
298+
contents: `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)`
299+
}
300+
}
301+
}
302+
)
303+
282304
// yarn 2 pnp compat
283305
if (isRunningWithYarnPnp) {
284306
build.onResolve(

packages/vite/src/node/plugins/resolve.ts

+35
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
isPossibleTsOutput,
3131
isTsRequest,
3232
isWindows,
33+
lookupFile,
3334
nestedResolveFrom,
3435
normalizePath,
3536
resolveFrom,
@@ -44,6 +45,8 @@ import { loadPackageData, resolvePackageData } from '../packages'
4445
// special id for paths marked with browser: false
4546
// https://github.com/defunctzombie/package-browser-field-spec#ignore-a-module
4647
export const browserExternalId = '__vite-browser-external'
48+
// special id for packages that are optional peer deps
49+
export const optionalPeerDepId = '__vite-optional-peer-dep'
4750

4851
const isDebug = process.env.DEBUG
4952
const debug = createDebugger('vite:resolve-details', {
@@ -365,6 +368,14 @@ export default new Proxy({}, {
365368
})`
366369
}
367370
}
371+
if (id.startsWith(optionalPeerDepId)) {
372+
if (isProduction) {
373+
return `export default {}`
374+
} else {
375+
const [, peerDep, parentDep] = id.split(':')
376+
return `throw new Error(\`Could not resolve "${peerDep}" imported by "${parentDep}". Is it installed?\`)`
377+
}
378+
}
368379
}
369380
}
370381
}
@@ -618,6 +629,30 @@ export function tryNodeResolve(
618629
})!
619630

620631
if (!pkg) {
632+
// if import can't be found, check if it's an optional peer dep.
633+
// if so, we can resolve to a special id that errors only when imported.
634+
if (
635+
basedir !== root && // root has no peer dep
636+
!isBuiltin(id) &&
637+
!id.includes('\0') &&
638+
bareImportRE.test(id)
639+
) {
640+
// find package.json with `name` as main
641+
const mainPackageJson = lookupFile(basedir, ['package.json'], {
642+
predicate: (content) => !!JSON.parse(content).name
643+
})
644+
if (mainPackageJson) {
645+
const mainPkg = JSON.parse(mainPackageJson)
646+
if (
647+
mainPkg.peerDependencies?.[id] &&
648+
mainPkg.peerDependenciesMeta?.[id]?.optional
649+
) {
650+
return {
651+
id: `${optionalPeerDepId}:${id}:${mainPkg.name}`
652+
}
653+
}
654+
}
655+
}
621656
return
622657
}
623658

packages/vite/src/node/utils.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ export function isDefined<T>(value: T | undefined | null): value is T {
390390
interface LookupFileOptions {
391391
pathOnly?: boolean
392392
rootDir?: string
393+
predicate?: (file: string) => boolean
393394
}
394395

395396
export function lookupFile(
@@ -400,7 +401,12 @@ export function lookupFile(
400401
for (const format of formats) {
401402
const fullPath = path.join(dir, format)
402403
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
403-
return options?.pathOnly ? fullPath : fs.readFileSync(fullPath, 'utf-8')
404+
const result = options?.pathOnly
405+
? fullPath
406+
: fs.readFileSync(fullPath, 'utf-8')
407+
if (!options?.predicate || options.predicate(result)) {
408+
return result
409+
}
404410
}
405411
}
406412
const parentDir = path.dirname(dir)

playground/optimize-deps/__tests__/optimize-deps.spec.ts

+13
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ test('dep with dynamic import', async () => {
8888
)
8989
})
9090

91+
test('dep with optional peer dep', async () => {
92+
expect(await page.textContent('.dep-with-optional-peer-dep')).toMatch(
93+
`[success]`
94+
)
95+
if (isServe) {
96+
expect(browserErrors.map((error) => error.message)).toEqual(
97+
expect.arrayContaining([
98+
'Could not resolve "foobar" imported by "dep-with-optional-peer-dep". Is it installed?'
99+
])
100+
)
101+
}
102+
})
103+
91104
test('dep with css import', async () => {
92105
expect(await getColor('.dep-linked-include')).toBe('red')
93106
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function callItself() {
2+
return '[success]'
3+
}
4+
5+
export async function callPeerDep() {
6+
return await import('foobar')
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "dep-with-optional-peer-dep",
3+
"private": true,
4+
"version": "0.0.0",
5+
"main": "index.js",
6+
"type": "module",
7+
"peerDependencies": {
8+
"foobar": "0.0.0"
9+
},
10+
"peerDependenciesMeta": {
11+
"foobar": {
12+
"optional": true
13+
}
14+
}
15+
}

playground/optimize-deps/index.html

+10
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ <h2>
5959
<h2>Import from dependency with dynamic import</h2>
6060
<div class="dep-with-dynamic-import"></div>
6161

62+
<h2>Import from dependency with optional peer dep</h2>
63+
<div class="dep-with-optional-peer-dep"></div>
64+
6265
<h2>Dep w/ special file format supported via plugins</h2>
6366
<div class="plugin"></div>
6467

@@ -152,6 +155,13 @@ <h2>Flatten Id</h2>
152155
text('.reused-variable-names', reusedName)
153156
</script>
154157

158+
<script type="module">
159+
import { callItself, callPeerDep } from 'dep-with-optional-peer-dep'
160+
text('.dep-with-optional-peer-dep', callItself())
161+
// expect error as optional peer dep not installed
162+
callPeerDep()
163+
</script>
164+
155165
<script type="module">
156166
// should error on builtin modules (named import)
157167
// no node: protocol intentionally

playground/optimize-deps/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"dep-with-builtin-module-cjs": "file:./dep-with-builtin-module-cjs",
2424
"dep-with-builtin-module-esm": "file:./dep-with-builtin-module-esm",
2525
"dep-with-dynamic-import": "file:./dep-with-dynamic-import",
26+
"dep-with-optional-peer-dep": "file:./dep-with-optional-peer-dep",
2627
"added-in-entries": "file:./added-in-entries",
2728
"lodash-es": "^4.17.21",
2829
"nested-exclude": "file:./nested-exclude",

pnpm-lock.yaml

+20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)