Skip to content

Commit 59ea27c

Browse files
hi-ogawaclaude
andauthored
fix: handle external/noExternal during configEnvironment hook (#9508)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8ce1fc5 commit 59ea27c

File tree

2 files changed

+106
-136
lines changed

2 files changed

+106
-136
lines changed

packages/vitest/src/node/plugins/runnerTransform.ts

Lines changed: 50 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import type { ResolvedConfig, UserConfig, Plugin as VitePlugin } from 'vite'
1+
import type { ResolveOptions, UserConfig, Plugin as VitePlugin } from 'vite'
22
import { builtinModules } from 'node:module'
33
import { normalize } from 'pathe'
4-
import { mergeConfig } from 'vite'
54
import { escapeRegExp } from '../../utils/base'
65
import { resolveOptimizerConfig } from './utils'
76

87
export function ModuleRunnerTransform(): VitePlugin {
8+
let testConfig: NonNullable<UserConfig['test']>
9+
const noExternal: (string | RegExp)[] = []
10+
const external: (string | RegExp)[] = []
11+
let noExternalAll = false
12+
913
// make sure Vite always applies the module runner transform
1014
return {
1115
name: 'vitest:environments-module-runner',
1216
config: {
1317
order: 'post',
1418
handler(config) {
15-
const testConfig = config.test || {}
19+
testConfig = config.test || {}
1620

1721
config.environments ??= {}
1822

@@ -53,11 +57,6 @@ export function ModuleRunnerTransform(): VitePlugin {
5357
testConfig.deps ??= {}
5458
testConfig.deps.moduleDirectories = moduleDirectories
5559

56-
const external: (string | RegExp)[] = []
57-
const noExternal: (string | RegExp)[] = []
58-
59-
let noExternalAll: true | undefined
60-
6160
for (const name of names) {
6261
config.environments[name] ??= {}
6362

@@ -73,117 +72,52 @@ export function ModuleRunnerTransform(): VitePlugin {
7372
}
7473
environment.dev.preTransformRequests = false
7574
environment.keepProcessEnv = true
75+
}
76+
},
77+
},
78+
configEnvironment: {
79+
order: 'post',
80+
handler(name, config) {
81+
if (name === '__vitest_vm__' || name === '__vitest__') {
82+
return
83+
}
7684

77-
const resolveExternal = name === 'client'
78-
? config.resolve?.external
79-
: []
80-
const resolveNoExternal = name === 'client'
81-
? config.resolve?.noExternal
82-
: []
83-
84-
const topLevelResolveOptions: UserConfig['resolve'] = {}
85-
if (resolveExternal != null) {
86-
topLevelResolveOptions.external = resolveExternal
87-
}
88-
if (resolveNoExternal != null) {
89-
topLevelResolveOptions.noExternal = resolveNoExternal
90-
}
91-
92-
const currentResolveOptions = mergeConfig(
93-
topLevelResolveOptions,
94-
environment.resolve || {},
95-
) as ResolvedConfig['resolve']
96-
97-
const envNoExternal = resolveViteResolveOptions('noExternal', currentResolveOptions, moduleDirectories)
98-
if (envNoExternal === true) {
99-
noExternalAll = true
100-
}
101-
else if (envNoExternal.length) {
102-
noExternal.push(...envNoExternal)
103-
}
104-
else if (name === 'client' || name === 'ssr') {
105-
const deprecatedNoExternal = resolveDeprecatedOptions(
106-
name === 'client'
107-
? config.resolve?.noExternal
108-
: config.ssr?.noExternal,
109-
moduleDirectories,
110-
)
111-
if (deprecatedNoExternal === true) {
112-
noExternalAll = true
113-
}
114-
else {
115-
noExternal.push(...deprecatedNoExternal)
116-
}
117-
}
118-
119-
const envExternal = resolveViteResolveOptions('external', currentResolveOptions, moduleDirectories)
120-
if (envExternal !== true && envExternal.length) {
121-
external.push(...envExternal)
122-
}
123-
else if (name === 'client' || name === 'ssr') {
124-
const deprecatedExternal = resolveDeprecatedOptions(
125-
name === 'client'
126-
? config.resolve?.external
127-
: config.ssr?.external,
128-
moduleDirectories,
129-
)
130-
if (deprecatedExternal !== true) {
131-
external.push(...deprecatedExternal)
132-
}
133-
}
134-
135-
// remove Vite's externalization logic because we have our own (unfortunetly)
136-
environment.resolve ??= {}
137-
138-
environment.resolve.external = [
139-
...builtinModules,
140-
...builtinModules.map(m => `node:${m}`),
141-
]
142-
// by setting `noExternal` to `true`, we make sure that
143-
// Vite will never use its own externalization mechanism
144-
// to externalize modules and always resolve static imports
145-
// in both SSR and Client environments
146-
environment.resolve.noExternal = true
147-
148-
// Workaround `noExternal` merging bug on Vite 6
149-
// https://github.com/vitejs/vite/pull/20502
150-
if (name === 'ssr') {
151-
delete config.ssr?.noExternal
152-
delete config.ssr?.external
153-
}
154-
155-
if (name === '__vitest_vm__' || name === '__vitest__') {
156-
continue
157-
}
158-
159-
const currentOptimizeDeps = environment.optimizeDeps || (
160-
name === 'client'
161-
? config.optimizeDeps
162-
: name === 'ssr'
163-
? config.ssr?.optimizeDeps
164-
: undefined
165-
)
166-
167-
const optimizeDeps = resolveOptimizerConfig(
168-
testConfig.deps?.optimizer?.[name],
169-
currentOptimizeDeps,
170-
)
85+
config.resolve ??= {}
86+
const envNoExternal = resolveViteResolveOptions('noExternal', config.resolve, testConfig.deps?.moduleDirectories)
87+
if (envNoExternal === true) {
88+
noExternalAll = true
89+
}
90+
else if (envNoExternal.length) {
91+
noExternal.push(...envNoExternal)
92+
}
17193

172-
// Vite respects the root level optimize deps, so we override it instead
173-
if (name === 'client') {
174-
config.optimizeDeps = optimizeDeps
175-
environment.optimizeDeps = undefined
176-
}
177-
else if (name === 'ssr') {
178-
config.ssr ??= {}
179-
config.ssr.optimizeDeps = optimizeDeps
180-
environment.optimizeDeps = undefined
181-
}
182-
else {
183-
environment.optimizeDeps = optimizeDeps
184-
}
94+
const envExternal = resolveViteResolveOptions('external', config.resolve, testConfig.deps?.moduleDirectories)
95+
if (envExternal !== true && envExternal.length) {
96+
external.push(...envExternal)
18597
}
18698

99+
// remove Vite's externalization logic because we have our own (unfortunately)
100+
config.resolve.external = [
101+
...builtinModules,
102+
...builtinModules.map(m => `node:${m}`),
103+
]
104+
105+
// by setting `noExternal` to `true`, we make sure that
106+
// Vite will never use its own externalization mechanism
107+
// to externalize modules and always resolve static imports
108+
// in both SSR and Client environments
109+
config.resolve.noExternal = true
110+
111+
config.optimizeDeps = resolveOptimizerConfig(
112+
testConfig?.deps?.optimizer?.[name],
113+
config.optimizeDeps,
114+
)
115+
},
116+
},
117+
configResolved: {
118+
order: 'pre',
119+
handler(config) {
120+
const testConfig = config.test!
187121
testConfig.server ??= {}
188122
testConfig.server.deps ??= {}
189123

@@ -207,7 +141,7 @@ export function ModuleRunnerTransform(): VitePlugin {
207141

208142
function resolveViteResolveOptions(
209143
key: 'noExternal' | 'external',
210-
options: ResolvedConfig['resolve'],
144+
options: Pick<ResolveOptions, 'noExternal' | 'external'>,
211145
moduleDirectories: string[] | undefined,
212146
): true | (string | RegExp)[] {
213147
if (Array.isArray(options[key])) {
@@ -229,22 +163,6 @@ function resolveViteResolveOptions(
229163
return []
230164
}
231165

232-
function resolveDeprecatedOptions(
233-
options: string | RegExp | (string | RegExp)[] | true | undefined,
234-
moduleDirectories: string[] | undefined,
235-
): true | (string | RegExp)[] {
236-
if (options === true) {
237-
return true
238-
}
239-
else if (Array.isArray(options)) {
240-
return options.map(dep => processWildcard(dep, moduleDirectories))
241-
}
242-
else if (options != null) {
243-
return [processWildcard(options, moduleDirectories)]
244-
}
245-
return []
246-
}
247-
248166
function processWildcard(dep: string | RegExp, moduleDirectories: string[] | undefined) {
249167
if (typeof dep !== 'string') {
250168
return dep

test/config/test/vite-ssr-resolve.test.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Plugin } from 'vite'
12
import type { CliOptions } from 'vitest/node'
23
import { join } from 'pathe'
34
import { describe, expect, onTestFinished, test } from 'vitest'
@@ -269,16 +270,66 @@ describe.each(['deprecated', 'environment'] as const)('VitestResolver with Vite
269270
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/style.css?inline&lang=scss')).toBe(false)
270271
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/Component.vue?vue&type=template&lang=pug')).toBeUndefined()
271272
})
273+
274+
// Test that plugins can set noExternal/external in configEnvironment hook
275+
// This simulates frameworks like Astro that add their packages via configEnvironment
276+
test('collects noExternal/external from plugin configEnvironment', async () => {
277+
const plugin: Plugin = {
278+
name: 'test-plugin',
279+
configEnvironment(name) {
280+
if (name === 'ssr') {
281+
return {
282+
resolve: {
283+
noExternal: ['plugin-inline-dep', '@framework/*'],
284+
external: ['plugin-external-dep'],
285+
},
286+
}
287+
}
288+
},
289+
}
290+
291+
// Also test merging with user config
292+
const resolver = await getResolver(style, {}, {
293+
noExternal: ['user-inline-dep'],
294+
external: ['user-external-dep'],
295+
}, [plugin])
296+
297+
// user config noExternal: should be inlined
298+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/user-inline-dep/index.js')).toBe(false)
299+
300+
// plugin noExternal: should be inlined
301+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/plugin-inline-dep/index.js')).toBe(false)
302+
303+
// plugin noExternal with wildcard: should be inlined
304+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@framework/core/index.js')).toBe(false)
305+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@framework/utils/index.js')).toBe(false)
306+
307+
// user config external: should be externalized
308+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/user-external-dep/index.js')).toBeTruthy()
309+
310+
// plugin external: should be externalized
311+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/plugin-external-dep/index.js')).toBeTruthy()
312+
313+
// other deps: default behavior
314+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/other-dep/index.cjs.js')).toBeTruthy()
315+
expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@other/lib/index.cjs.js')).toBeTruthy()
316+
})
272317
})
273318

274-
async function getResolver(style: 'environment' | 'deprecated', options: CliOptions, externalOptions: {
275-
external?: true | string[]
276-
noExternal?: true | string | RegExp | (string | RegExp)[]
277-
}) {
319+
async function getResolver(
320+
style: 'environment' | 'deprecated',
321+
options: CliOptions,
322+
externalOptions: {
323+
external?: true | string[]
324+
noExternal?: true | string | RegExp | (string | RegExp)[]
325+
},
326+
plugins: Plugin[] = [],
327+
) {
278328
const ctx = await createVitest('test', {
279329
watch: false,
280330
}, style === 'environment'
281331
? {
332+
plugins,
282333
environments: {
283334
ssr: {
284335
resolve: externalOptions,
@@ -287,6 +338,7 @@ async function getResolver(style: 'environment' | 'deprecated', options: CliOpti
287338
test: options,
288339
}
289340
: {
341+
plugins,
290342
ssr: externalOptions,
291343
test: options,
292344
})

0 commit comments

Comments
 (0)