Skip to content

Commit d87c96e

Browse files
authored
feat!: share the Vite server between inline projects (#10848)
1 parent 77aac87 commit d87c96e

25 files changed

Lines changed: 1270 additions & 355 deletions

File tree

docs/.vitepress/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,10 @@ export default ({ mode }: { mode: string }) => {
542542
text: 'projects',
543543
link: '/config/projects',
544544
},
545+
{
546+
text: 'sharedViteServer',
547+
link: '/config/sharedviteserver',
548+
},
545549
{
546550
text: 'isolate',
547551
link: '/config/isolate',

docs/api/advanced/test-project.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,15 @@ It is based on the root of the project and its name. Note that the root path is
9898

9999
## vite
100100

101-
This is project's [`ViteDevServer`](https://vite.dev/guide/api-javascript#vitedevserver). All projects have their own Vite servers.
101+
This is project's [`ViteDevServer`](https://vite.dev/guide/api-javascript#vitedevserver). Note that the server is not necessarily exclusive to this project: other projects can reuse it when the [`sharedViteServer`](/config/sharedviteserver) option applies, and browser instances of the same cluster share a single browser server.
102+
103+
## sharedViteServer
104+
105+
```ts
106+
const sharedViteServer: boolean
107+
```
108+
109+
`true` when the project reuses the Vite server of the config that declared it instead of resolving its own (see the [`sharedViteServer`](/config/sharedviteserver) option). The project that owns the server reports `false` even when other projects reuse it. To detect any two projects sharing a server (including browser instances), compare their [`vite`](#vite) references.
102110
103111
## browser
104112

docs/config/sharedviteserver.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
title: sharedViteServer | Config
3+
outline: deep
4+
---
5+
6+
# sharedViteServer <CRoot />
7+
8+
- **Type:** `boolean`
9+
- **Default:** `true`
10+
- **CLI:** `--sharedViteServer=false`
11+
12+
Inline [projects](/guide/projects) that don't modify the Vite config reuse the Vite server of the config that declares them. Instead of resolving a new Vite config and creating a new server for every project, such projects share the declaring config's server and its transform cache, so shared source files are transformed once instead of once per project and tests run faster. The performance improvement varies depending on the number of inline projects and how many source files they have in common.
13+
14+
This option _only_ applies to inline projects. Projects referenced as config files or directories always resolve their own Vite config and create their own server.
15+
16+
A project still gets its own Vite server when it defines Vite-level options that change the server (`plugins`, `resolve`, and so on), when its `extends` doesn't point to the declaring config, or when it defines test options that affect the Vite config:
17+
18+
- [`alias`](/config/alias)
19+
- [`browser`](/config/browser/enabled)
20+
- [`css`](/config/css)
21+
- [`deps.moduleDirectories`](/config/deps#deps-moduledirectories)
22+
- [`deps.optimizer`](/config/deps#deps-optimizer)
23+
- `mode`
24+
- [`root`](/config/root)
25+
26+
Options like `env`, `setupFiles`, `server.deps`, or `environment` don't prevent sharing: every project keeps its own module resolution rules, module runner, and module instances on top of the shared server.
27+
28+
The same applies to Vite-level values that don't change the server: an empty `plugins` list (`plugins: isCI ? [ciPlugin()] : []`) and `define`.
29+
30+
```ts [vitest.config.ts]
31+
import { defineConfig } from 'vitest/config'
32+
33+
export default defineConfig({
34+
test: {
35+
projects: [
36+
// these projects share the root Vite server
37+
{ test: { name: 'unit', include: ['**/*.unit.test.ts'] } },
38+
{ test: { name: 'integration', include: ['**/*.integration.test.ts'] } },
39+
// `define` doesn't create a new server, so this project also shares it
40+
{ define: { __DEV__: 'true' }, test: { name: 'dev' } },
41+
// this project resolves its own Vite config because of `alias`
42+
{ test: { name: 'aliased', alias: { lib: './src/lib' } } },
43+
],
44+
},
45+
})
46+
```
47+
48+
::: tip
49+
If every project repeats the same `plugins` entry, move it to the declaring config. The projects inherit it from the shared server and keep sharing:
50+
51+
```ts [vitest.config.ts]
52+
import react from '@vitejs/plugin-react'
53+
import { defineConfig } from 'vitest/config'
54+
55+
export default defineConfig({
56+
// hoisted: instantiated once on the shared server
57+
plugins: [react()],
58+
test: {
59+
projects: [
60+
{ test: { name: 'unit', include: ['**/*.unit.test.ts'] } },
61+
{ test: { name: 'integration', include: ['**/*.integration.test.ts'] } },
62+
],
63+
},
64+
})
65+
```
66+
:::
67+
68+
To see the decision for every project, including why a project resolves its own server, run Vitest with `DEBUG=vitest:projects`. API consumers can check whether a project reuses the declaring config's server via [`project.sharedViteServer`](/api/advanced/test-project#sharedviteserver).
69+
70+
The option applies to every level: inline projects of a [nested projects container](/guide/projects#nested-projects) share the container's server the same way.
71+
72+
::: warning
73+
When projects share a server, the declaring config file is executed once instead of once per project. Plugins are instantiated once, and their `config` hooks cannot observe per-project test options. If a plugin needs to behave differently per project, disable this option or don't share the server for that project (for example, set `extends: false` or define the project in its own config file).
74+
:::

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,13 @@ Run only tests with the specified tags. You can use logical operators `&&` (and)
930930

931931
Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`)
932932

933+
### sharedViteServer
934+
935+
- **CLI:** `--sharedViteServer`
936+
- **Config:** [sharedViteServer](/config/sharedviteserver)
937+
938+
Let inline projects that don't modify the Vite config reuse the Vite server of the config that declares them. (default: `true`)
939+
933940
### experimental.importDurations.print
934941

935942
- **CLI:** `--experimental.importDurations.print <boolean|on-warn>`

docs/guide/migration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,32 @@ Since the inherited `projects` paths resolve relative to the referenced config,
157157

158158
Inline configurations continue to ignore the `projects` field at runtime, but it is now also excluded from their `ProjectConfig` type.
159159

160+
### Inline Projects Share the Vite Server by Default
161+
162+
Inline projects that don't modify the Vite config now reuse the Vite server of the config that declares them instead of resolving a new Vite config and creating a new server per project. This is controlled by the new [`sharedViteServer`](/config/sharedviteserver) option, which is enabled by default.
163+
164+
Sharing the server means files are transformed once instead of once per project, so tests should now run faster: a config with several inline projects over the same codebase benefits the most, while a config with a single project won't see a difference.
165+
166+
Note that this _only_ applies to inline projects. Projects referenced as config files or directories always resolve their own Vite config and create their own server, exactly as before.
167+
168+
An inline project also still gets its own Vite server when it defines Vite-level options that change the server (`plugins`, `resolve`, and so on), a non-default `extends`, or test options that affect the Vite config: `alias`, `browser`, `css`, `deps.moduleDirectories`, `deps.optimizer`, `mode`, or `root`. Every project keeps its own module resolution rules, module runner, and module instances, so options like `env`, `setupFiles`, `server.deps`, or `environment` still resolve per project.
169+
170+
The observable change: when the server is shared, the declaring config file is executed once instead of once per project, so its plugins are instantiated once and their `config` hooks no longer run for every project. If a plugin relies on being re-instantiated per project, disable the sharing:
171+
172+
```ts [vitest.config.ts]
173+
import { defineConfig } from 'vitest/config'
174+
175+
export default defineConfig({
176+
test: {
177+
sharedViteServer: false, // [!code ++]
178+
projects: [
179+
{ test: { name: 'unit' } },
180+
{ test: { name: 'integration' } },
181+
],
182+
},
183+
})
184+
```
185+
160186
### Hoisted Mocking Calls Must Be at the Top Level
161187

162188
[`vi.mock`](/api/vi#vi-mock), [`vi.unmock`](/api/vi#vi-unmock), and [`vi.hoisted`](/api/vi#vi-hoisted) are hoisted to the top of the file and run before any surrounding code. Calling them inside a function, block, or `describe`/`test` callback previously only logged a warning. Vitest 5.0 now throws, because the call does not execute where it is written:

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
887887
strictTags: {
888888
description: 'Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`)',
889889
},
890+
sharedViteServer: {
891+
description: 'Let inline projects that don\'t modify the Vite config reuse the Vite server of the config that declares them. (default: `true`)',
892+
},
890893

891894
experimental: {
892895
description: 'Experimental features.',

packages/vitest/src/node/config/resolveConfig.ts

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import type {
22
InlineConfig,
33
ResolvedConfig as ResolvedViteConfig,
4+
Plugin as VitePlugin,
45
UserConfig as ViteUserConfig,
56
} from 'vite'
67
import type { Logger } from '../logger'
7-
import type { BrowserContributionHolder } from '../plugins/browserLoader'
88
import type {
99
ApiConfig,
10+
ConfigResolutionCaptures,
1011
ResolvedConfig,
1112
UserConfig,
1213
} from '../types/config'
1314
import type { CoverageOptions, CoverageReporterWithOptions } from '../types/coverage'
1415
import { existsSync, statSync } from 'node:fs'
1516
import { pathToFileURL } from 'node:url'
16-
import { deepMerge, slash, toArray } from '@vitest/utils/helpers'
17+
import { deepClone, deepMerge, slash, toArray } from '@vitest/utils/helpers'
1718
import { resolveModule } from 'local-pkg'
1819
import { join, normalize, relative, resolve } from 'pathe'
1920
import { isDynamicPattern } from 'tinyglobby'
@@ -28,10 +29,9 @@ import { wildcardPatternToRegExp } from '../../utils/base'
2829
import { isAgent, isCI, stdProvider } from '../../utils/env'
2930
import { getWorkersCountByPercentage } from '../../utils/workers'
3031
import { BrowserLoaderPlugin } from '../plugins/browserLoader'
31-
import { CliOverride } from '../plugins/cliOverride'
32-
import { VitestConfig } from '../plugins/config'
32+
import { ViteConfigPlugin } from '../plugins/config'
3333
import { VitestCorePlugin } from '../plugins/index'
34-
import { VitestConfigServer } from '../plugins/server'
34+
import { TestConfigPlugin } from '../plugins/testConfig'
3535
import { resolveFsAllow } from '../plugins/utils'
3636
import { resolveProjectEntries } from '../projects/resolveProjects'
3737
import { withLabel } from '../reporters/renderers/utils'
@@ -997,6 +997,7 @@ export function resolveTestConfig(
997997
resolved.hookTimeout ??= resolved.browser.enabled ? 30_000 : 10_000
998998

999999
resolved.experimental ??= {} as any
1000+
resolved.sharedViteServer ??= true
10001001
if (resolved.experimental.openTelemetry?.sdkPath) {
10011002
const sdkPath = resolve(
10021003
resolved.root,
@@ -1065,6 +1066,40 @@ export function resolveTestConfig(
10651066
return resolved
10661067
}
10671068

1069+
/**
1070+
* Captures `config.test` before any Vitest plugin modifies it. Inline projects
1071+
* that share this config's Vite server resolve against the captured value
1072+
* instead of re-executing the config file (`sharedViteServer`).
1073+
*
1074+
* Must be the first inline plugin. The consumer must clear
1075+
* `captures.rawTestConfig` after storing it because the server retains the plugin.
1076+
*/
1077+
export function CaptureRawTestConfig(captures: ConfigResolutionCaptures, capture: boolean | undefined): VitePlugin {
1078+
return {
1079+
name: 'vitest:capture-raw-test-config',
1080+
enforce: 'pre',
1081+
config: {
1082+
order: 'pre',
1083+
handler(config) {
1084+
if (!(capture ?? config.test?.sharedViteServer ?? true)) {
1085+
return
1086+
}
1087+
const { projects, ...test } = (config.test ?? {}) as UserConfig
1088+
// the captured config is only read when this config's inline
1089+
// projects are resolved; without `projects` there is nothing to
1090+
// read it (`injectTestProjects` then resolves through Vite instead)
1091+
if (projects === undefined) {
1092+
return
1093+
}
1094+
// cloned so mutations from later hooks and from the test-config
1095+
// resolution never reach the captured value; `projects` is left out
1096+
// because it is never inherited and can be the largest part of the config
1097+
captures.rawTestConfig = deepClone(test) as UserConfig
1098+
},
1099+
},
1100+
}
1101+
}
1102+
10681103
function resolveConfigPath(root: string, options: UserConfig) {
10691104
if (options.config === false) {
10701105
return false
@@ -1081,25 +1116,27 @@ export async function resolveConfig(
10811116
pluginsHarness: PluginHarness = new PluginHarness(),
10821117
): Promise<ResolvedViteConfig> {
10831118
// We clone CLI Options and Vite overrides to reuse when a watch mode is triggered.
1084-
const cliOptionsCopy = deepMerge({}, options)
1085-
const viteOverridesCopy = deepMerge({}, viteOverrides)
1119+
const cliOptionsCopy = deepMerge({}, options) as UserConfig
1120+
const viteOverridesCopy = deepMerge({}, viteOverrides) as ViteUserConfig
10861121
const root = resolve(options.root || process.cwd())
10871122
const configPath = resolveConfigPath(root, options)
10881123
options.config = configPath
10891124
options.root = root
10901125

1091-
const rootBrowserHolder: BrowserContributionHolder = {}
1126+
const captures: ConfigResolutionCaptures = {}
10921127
const inlineConfig: InlineConfig = mergeConfig(
10931128
{
10941129
configFile: configPath,
10951130
configLoader: options.configLoader,
10961131
mode: options.mode || 'test',
10971132
plugins: [
1098-
CliOverride(cliOptionsCopy),
1099-
...VitestConfigServer(pluginsHarness),
1100-
...VitestConfig(pluginsHarness),
1133+
// the capture hook runs before `vitest:config:cli`, so `--sharedViteServer`
1134+
// has to be passed directly instead of being read from `config.test`
1135+
CaptureRawTestConfig(captures, cliOptionsCopy.sharedViteServer),
1136+
...TestConfigPlugin(pluginsHarness, captures, cliOptionsCopy),
1137+
...ViteConfigPlugin(pluginsHarness),
11011138
...VitestCorePlugin(pluginsHarness, options),
1102-
...BrowserLoaderPlugin(rootBrowserHolder, pluginsHarness),
1139+
...BrowserLoaderPlugin(captures, pluginsHarness),
11031140
],
11041141
} satisfies InlineConfig,
11051142
mergeConfig(viteOverrides, { root }),
@@ -1152,7 +1189,25 @@ export async function resolveConfig(
11521189

11531190
rootConfig.cliOptions = cliOptionsCopy
11541191
rootConfig.viteOverrides = viteOverridesCopy
1155-
rootConfig._browserContribution = rootBrowserHolder.contribution
1192+
rootConfig._browserContribution = captures.browserContribution
1193+
// projects never inherit `tagsFilter` and `browser` from the programmatic
1194+
// config (see `inheritRootViteOverrides`), so remove them from the base too
1195+
if (captures.rawTestConfig) {
1196+
const overridesTest = (viteOverridesCopy as ViteUserConfig).test as UserConfig | undefined
1197+
if (overridesTest?.tagsFilter !== undefined) {
1198+
delete captures.rawTestConfig.tagsFilter
1199+
}
1200+
if (overridesTest?.browser !== undefined) {
1201+
delete captures.rawTestConfig.browser
1202+
}
1203+
}
1204+
// the root keeps the config for the whole session so `injectTestProjects`
1205+
// can resolve shared-server projects at any point
1206+
rootConfig._rawTestConfig = captures.rawTestConfig
1207+
rootConfig._moduleRunnerOptions = captures.moduleRunnerOptions
1208+
// `captures` lives as long as the server that keeps its plugins,
1209+
// so it should not hold onto the config
1210+
captures.rawTestConfig = undefined
11561211

11571212
rootConfig.resolvedProjects = await resolveProjectEntries(
11581213
pluginsHarness,

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,11 @@ import type {
66
import type { PluginHarness } from '../config/pluginHarness'
77
import type { Vitest } from '../core'
88
import type {
9-
BrowserServerContribution,
109
ParentProjectBrowser,
1110
} from '../types/browser'
12-
import type { ResolvedConfig, ResolvedProjectEntry } from '../types/config'
11+
import type { ConfigResolutionCaptures, ResolvedConfig, ResolvedProjectEntry } from '../types/config'
1312
import { createViteServer } from '../vite'
1413

15-
export interface BrowserContributionHolder {
16-
contribution?: BrowserServerContribution
17-
}
18-
1914
function sortPluginsByEnforce(plugins: VitePlugin[]): VitePlugin[] {
2015
const pre: VitePlugin[] = []
2116
const normal: VitePlugin[] = []
@@ -35,7 +30,7 @@ function sortPluginsByEnforce(plugins: VitePlugin[]): VitePlugin[] {
3530
}
3631

3732
export function BrowserLoaderPlugin(
38-
holder: BrowserContributionHolder,
33+
captures: ConfigResolutionCaptures,
3934
harness: PluginHarness,
4035
): VitePlugin[] {
4136
return [
@@ -60,12 +55,12 @@ export function BrowserLoaderPlugin(
6055
throw new Error(`Browser Mode was enabled, but provider was not specified anywhere. See https://vitest.dev/guide/browser/#configuration`)
6156
}
6257
const contribution = await provider.serverFactory()
63-
holder.contribution = contribution
58+
captures.browserContribution = contribution
6459
const browserConfig = await contribution.config(viteConfig, harness)
6560
return browserConfig
6661
},
6762
applyToEnvironment(environment) {
68-
const contribution = holder.contribution
63+
const contribution = captures.browserContribution
6964
if (contribution && environment.name === 'client') {
7065
// `post` browser plugins are injected by `vitest:browser:loader:post`
7166
// instead, so they run after the `post` plugins of the main pipeline
@@ -81,21 +76,21 @@ export function BrowserLoaderPlugin(
8176
configureServer: {
8277
order: 'pre',
8378
async handler(server) {
84-
await holder.contribution?.configureServer(server)
79+
await captures.browserContribution?.configureServer(server)
8580
},
8681
},
8782
transformIndexHtml: {
8883
order: 'pre',
8984
async handler(html, ctx) {
90-
return holder.contribution?.transformIndexHtml(ctx)
85+
return captures.browserContribution?.transformIndexHtml(ctx)
9186
},
9287
},
9388
},
9489
{
9590
name: 'vitest:browser:loader:post',
9691
enforce: 'post',
9792
applyToEnvironment(environment) {
98-
const contribution = holder.contribution
93+
const contribution = captures.browserContribution
9994
if (contribution && environment.name === 'client') {
10095
return sortPluginsByEnforce(
10196
contribution.plugins.filter(plugin => plugin.enforce === 'post'),

0 commit comments

Comments
 (0)