[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: handle `external/noExternal` during `configEnvironment` hook (#9508)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by

Hiroshi Ogawa
Claude Opus 4.5
and committed by
GitHub
(Jan 24, 2026, 12:20 PM +0100) 59ea27c1 8ce1fc55

+108 -138
+56 -4
test/config/test/vite-ssr-resolve.test.ts
··· 1 + import type { Plugin } from 'vite' 1 2 import type { CliOptions } from 'vitest/node' 2 3 import { join } from 'pathe' 3 4 import { describe, expect, onTestFinished, test } from 'vitest' ··· 269 270 expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/style.css?inline&lang=scss')).toBe(false) 270 271 expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/Component.vue?vue&type=template&lang=pug')).toBeUndefined() 271 272 }) 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 + }) 272 317 }) 273 318 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 + ) { 278 328 const ctx = await createVitest('test', { 279 329 watch: false, 280 330 }, style === 'environment' 281 331 ? { 332 + plugins, 282 333 environments: { 283 334 ssr: { 284 335 resolve: externalOptions, ··· 287 338 test: options, 288 339 } 289 340 : { 341 + plugins, 290 342 ssr: externalOptions, 291 343 test: options, 292 344 })
+52 -134
packages/vitest/src/node/plugins/runnerTransform.ts
··· 1 - import type { ResolvedConfig, UserConfig, Plugin as VitePlugin } from 'vite' 1 + import type { ResolveOptions, UserConfig, Plugin as VitePlugin } from 'vite' 2 2 import { builtinModules } from 'node:module' 3 3 import { normalize } from 'pathe' 4 - import { mergeConfig } from 'vite' 5 4 import { escapeRegExp } from '../../utils/base' 6 5 import { resolveOptimizerConfig } from './utils' 7 6 8 7 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 + 9 13 // make sure Vite always applies the module runner transform 10 14 return { 11 15 name: 'vitest:environments-module-runner', 12 16 config: { 13 17 order: 'post', 14 18 handler(config) { 15 - const testConfig = config.test || {} 19 + testConfig = config.test || {} 16 20 17 21 config.environments ??= {} 18 22 ··· 53 57 testConfig.deps ??= {} 54 58 testConfig.deps.moduleDirectories = moduleDirectories 55 59 56 - const external: (string | RegExp)[] = [] 57 - const noExternal: (string | RegExp)[] = [] 58 - 59 - let noExternalAll: true | undefined 60 - 61 60 for (const name of names) { 62 61 config.environments[name] ??= {} 63 62 ··· 73 72 } 74 73 environment.dev.preTransformRequests = false 75 74 environment.keepProcessEnv = true 76 - 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 - ) 171 - 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 - } 75 + } 76 + }, 77 + }, 78 + configEnvironment: { 79 + order: 'post', 80 + handler(name, config) { 81 + if (name === '__vitest_vm__' || name === '__vitest__') { 82 + return 185 83 } 186 84 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 + } 93 + 94 + const envExternal = resolveViteResolveOptions('external', config.resolve, testConfig.deps?.moduleDirectories) 95 + if (envExternal !== true && envExternal.length) { 96 + external.push(...envExternal) 97 + } 98 + 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! 187 121 testConfig.server ??= {} 188 122 testConfig.server.deps ??= {} 189 123 ··· 207 141 208 142 function resolveViteResolveOptions( 209 143 key: 'noExternal' | 'external', 210 - options: ResolvedConfig['resolve'], 144 + options: Pick<ResolveOptions, 'noExternal' | 'external'>, 211 145 moduleDirectories: string[] | undefined, 212 146 ): true | (string | RegExp)[] { 213 147 if (Array.isArray(options[key])) { ··· 225 159 } 226 160 else if (typeof options[key] === 'boolean') { 227 161 return true 228 - } 229 - return [] 230 - } 231 - 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 162 } 245 163 return [] 246 164 }