diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index 1ab4291bd67edd..929683868880a9 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -833,7 +833,7 @@ async function prepareRolldownOptimizerRun( const plugins = await asyncFlatten(arraify(pluginsFromConfig)) if (external.length) { - plugins.push(rolldownCjsExternalPlugin(external, platform)) + plugins.push(rolldownCjsExternalPlugin(external, platform, environment)) } plugins.push(...rolldownDepPlugin(environment, flatIdDeps, external)) diff --git a/packages/vite/src/node/optimizer/rolldownDepPlugin.ts b/packages/vite/src/node/optimizer/rolldownDepPlugin.ts index 07d8cab4566b40..1c974d5b965431 100644 --- a/packages/vite/src/node/optimizer/rolldownDepPlugin.ts +++ b/packages/vite/src/node/optimizer/rolldownDepPlugin.ts @@ -376,6 +376,7 @@ const matchesEntireLine = (text: string) => `^${escapeRegex(text)}$` export function rolldownCjsExternalPlugin( externals: string[], platform: 'node' | 'browser' | 'neutral', + environment: Environment, ): Plugin | undefined { // Skip this plugin for `platform: 'node'` as `require` is available in Node // and that is more accurate than converting to `import` @@ -393,11 +394,17 @@ export function rolldownCjsExternalPlugin( const filter = new RegExp(externals.map(matchesEntireLine).join('|')) + const packageCache: PackageCache = new Map() + const resolveRequire = createBackCompatIdResolver( + environment.getTopLevelConfig(), + { asSrc: false, isRequire: true, scan: true, packageCache }, + ) + return { name: 'cjs-external', resolveId: { filter: { id: [prefixRegex(nonFacadePrefix), filter] }, - handler(id, _importer, options) { + async handler(id, importer, options) { if (id.startsWith(nonFacadePrefix)) { return { id: id.slice(nonFacadePrefix.length), @@ -405,6 +412,18 @@ export function rolldownCjsExternalPlugin( } } if (options.kind === 'require-call') { + // A missing optional peer must throw when require() runs, not when + // an eager ESM facade is evaluated. Reuse the pre-bundler's CJS stub + // so the dependency's try/catch can still select its fallback. + // Other excluded deps may intentionally be unresolvable until served. + const resolved = await resolveRequire( + environment, + id, + importer, + ).catch(() => undefined) + if (resolved?.startsWith(optionalPeerDepId)) { + return { id: optionalPeerDepNamespace + resolved } + } return { id: cjsExternalFacadeNamespace + id, } diff --git a/playground/optimize-deps/__tests__/optimize-deps.spec.ts b/playground/optimize-deps/__tests__/optimize-deps.spec.ts index d6febaafad2c17..669405ec0512a4 100644 --- a/playground/optimize-deps/__tests__/optimize-deps.spec.ts +++ b/playground/optimize-deps/__tests__/optimize-deps.spec.ts @@ -179,6 +179,20 @@ test('dep with optional peer dep (cjs)', async () => { .toMatch(`[success]`) }) +test.runIf(isServe)( + 'optimized CJS dep preserves fallback for excluded optional peer', + async () => { + const metadata = readDepOptimizationMetadata() + expect(Object.keys(metadata.optimized)).toContain( + '@vitejs/test-dep-with-excluded-optional-peer-dep-cjs', + ) + + await expect + .poll(() => page.textContent('.dep-with-excluded-optional-peer-dep-cjs')) + .toMatch(`[success]`) + }, +) + test('dep with css import', async () => { await expect.poll(() => getColor('.dep-linked-include')).toBe('red') }) diff --git a/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/index.js b/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/index.js new file mode 100644 index 00000000000000..5b9c4ea42e4c12 --- /dev/null +++ b/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/index.js @@ -0,0 +1,9 @@ +exports.callPeerDep = function () { + try { + // This should not be hoisted as static import + // eslint-disable-next-line n/no-missing-require + return require('@vitejs/test-excluded-optional-peer') + } catch { + return 'fallback' + } +} diff --git a/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/package.json b/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/package.json new file mode 100644 index 00000000000000..68555d05d59fe3 --- /dev/null +++ b/playground/optimize-deps/dep-with-excluded-optional-peer-dep-cjs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@vitejs/test-dep-with-excluded-optional-peer-dep-cjs", + "private": true, + "version": "0.0.0", + "main": "index.js", + "peerDependencies": { + "@vitejs/test-excluded-optional-peer": "0.0.0" + }, + "peerDependenciesMeta": { + "@vitejs/test-excluded-optional-peer": { + "optional": true + } + } +} diff --git a/playground/optimize-deps/index.html b/playground/optimize-deps/index.html index 2e3b6607dabff7..cb54740ab5add3 100644 --- a/playground/optimize-deps/index.html +++ b/playground/optimize-deps/index.html @@ -97,6 +97,9 @@

Import from dependency with optional peer dep (cjs)

+

Import from optimized dependency with excluded optional peer dep (cjs)

+
+

Externalize known non-js files in optimize included dep

@@ -311,6 +314,15 @@

Import the CommonJS external package that omits the js suffix

) + +