perf(browser): prewarm the browser while the Vite server starts (#10727)

This commit is contained in:
Vladimir
2026-07-23 10:39:17 +02:00
committed by GitHub
parent 026e038166
commit fb7714ea07
8 changed files with 341 additions and 32 deletions
+149 -24
View File
@@ -96,12 +96,136 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv
name: 'playwright',
supportedBrowser: playwrightBrowsers,
options,
prewarm(ctx) {
prewarmBrowser(ctx, options)
},
providerFactory(project) {
return new PlaywrightBrowserProvider(project, options)
},
})
}
interface WarmBrowser {
promise: Promise<Browser>
launchOptionsJson: string
pending: Set<WarmBrowser>
}
// the subset of `TestProject` the launch-option resolution needs; `prewarm`
// runs before the project exists and receives the same shape
interface LaunchContext {
config: TestProject['config']
vitest: TestProject['vitest']
}
// The resolved config object is passed unchanged to the eventual TestProject,
// so it identifies the browser this project can adopt.
const warmBrowsers = new WeakMap<LaunchContext['config'], WarmBrowser>()
const pendingWarmBrowsers = new WeakMap<LaunchContext['vitest'], Set<WarmBrowser>>()
// starts importing playwright and launching the browser while the node side
// is still creating the vite server, so the launch latency overlaps it. The
// launch options are resolved by the same code as the real launch — if they
// still differ by the time the provider opens the browser, the warm instance
// is discarded, so this is always safe
function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptions): void {
const browserName = project.config.browser.name
if (
options.connectOptions
|| options.persistentContext
// don't speculate on debugging flows
|| project.vitest.config.inspector.enabled
) {
return
}
if (!browserName || !(playwrightBrowsers as readonly string[]).includes(browserName)) {
return
}
if (warmBrowsers.has(project.config)) {
return
}
let pending = pendingWarmBrowsers.get(project.vitest)
if (!pending) {
const pendingBrowsers = new Set<WarmBrowser>()
pending = pendingBrowsers
pendingWarmBrowsers.set(project.vitest, pendingBrowsers)
// Browsers whose projects never initialize a provider (they have no test
// files to run) are cleaned up when Vitest closes.
project.vitest.onClose(() => closeWarmBrowsers(pendingBrowsers))
}
const launchOptions = resolveLaunchOptions(project.config.browser, project.vitest.config.inspector, options, browserName)
const entry: WarmBrowser = {
launchOptionsJson: JSON.stringify(launchOptions),
pending,
promise: (async () => {
debug?.('[%s] prewarming the browser', browserName)
const playwright = await import('playwright')
return playwright[browserName as PlaywrightBrowser].launch(launchOptions)
})(),
}
// if the warm launch fails, drop it so the real launch retries
// and surfaces the error through the normal path
entry.promise.catch(() => {
if (warmBrowsers.get(project.config) === entry) {
warmBrowsers.delete(project.config)
entry.pending.delete(entry)
}
})
pending.add(entry)
warmBrowsers.set(project.config, entry)
}
function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefined {
const warm = warmBrowsers.get(config)
if (warm) {
warmBrowsers.delete(config)
warm.pending.delete(warm)
}
return warm
}
async function closeWarmBrowsers(pending: Set<WarmBrowser>): Promise<void> {
const closing = Array.from(pending, warm => warm.promise.then(browser => browser.close()).catch(() => {}))
pending.clear()
await Promise.all(closing)
}
function resolveLaunchOptions(
browser: TestProject['config']['browser'],
inspector: TestProject['vitest']['config']['inspector'],
providerOptions: PlaywrightProviderOptions,
browserName: string,
): LaunchOptions {
const launchOptions: LaunchOptions = {
...providerOptions.launchOptions,
headless: browser.headless,
}
if (typeof browser.trace === 'object' && browser.trace.tracesDir) {
launchOptions.tracesDir = browser.trace.tracesDir
}
if (inspector.enabled) {
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
const port = inspector.port || 9229
launchOptions.args ||= []
launchOptions.args.push(`--remote-debugging-port=${port}`)
}
// start Vitest UI maximized only on supported browsers
if (browser.ui && browserName === 'chromium') {
if (!launchOptions.args) {
launchOptions.args = []
}
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
launchOptions.args.push('--start-maximized')
}
}
return launchOptions
}
export class PlaywrightBrowserProvider implements BrowserProvider {
public name = 'playwright' as const
public supportsParallelism = true
@@ -167,44 +291,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
}
this.browserPromise = (async () => {
const options = this.project.config.browser
const playwright = await import('playwright')
const launchOptions: LaunchOptions = {
...this.options.launchOptions,
headless: options.headless,
}
if (typeof options.trace === 'object' && options.trace.tracesDir) {
launchOptions.tracesDir = options.trace?.tracesDir
}
const launchOptions = resolveLaunchOptions(
this.project.config.browser,
this.project.vitest.config.inspector,
this.options,
this.browserName,
)
const inspector = this.project.vitest.config.inspector
if (inspector.enabled) {
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
const port = inspector.port || 9229
const host = inspector.host || '127.0.0.1'
launchOptions.args ||= []
launchOptions.args.push(`--remote-debugging-port=${port}`)
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
this.project.vitest.logger.warn(`Custom inspector host "${host}" will be ignored. Chromium only allows remote debugging on localhost.`)
}
this.project.vitest.logger.log(`Debugger listening on ws://127.0.0.1:${port}`)
}
// start Vitest UI maximized only on supported browsers
if (this.project.config.browser.ui && this.browserName === 'chromium') {
if (!launchOptions.args) {
launchOptions.args = []
}
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
launchOptions.args.push('--start-maximized')
}
}
debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions)
if (this.options.connectOptions) {
@@ -250,6 +356,20 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
this.browser = this.persistentContext.browser()!
}
else {
const warm = takeWarmBrowser(this.project.config)
if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) {
const browser = await warm.promise.catch(() => null)
if (browser?.isConnected()) {
debug?.('[%s] adopting the prewarmed browser', this.browserName)
this.browser = browser
this.browserPromise = null
return this.browser
}
}
else if (warm) {
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
void warm.promise.then(browser => browser.close()).catch(() => {})
}
this.browser = await playwright[this.browserName].launch(launchOptions)
}
this.browserPromise = null
@@ -553,6 +673,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
debug?.('[%s] closing provider', this.browserName)
this.closing = true
// a prewarmed browser that was never adopted must not outlive the provider
const warm = takeWarmBrowser(this.project.config)
if (warm) {
void warm.promise.then(browser => browser.close()).catch(() => {})
}
if (this.browserPromise) {
await this.browserPromise
this.browserPromise = null
@@ -416,6 +416,7 @@ export function resolveTestConfig(
+ `Use a single provider for the project, or move the instances into separate projects.`,
)
}
browser.provider ??= browser.instances.find(instance => instance.provider)?.provider
// use `chromium` by default when the preview provider is specified
// for a smoother experience. if chromium is not available, it will
+3 -1
View File
@@ -283,9 +283,11 @@ export class Vitest {
*/
async _attachRootServer(): Promise<void> {
const resolved = this.config
const children = resolved.resolvedProjects
.filter(entry => entry.viteConfig === this.viteConfig)
// For a root-level browser config (no `projects`) this builds the single
// browser server; otherwise it just creates the Vite server.
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved)
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved, children)
this.vite = server
this._rootBrowserParent = parent
@@ -9,7 +9,7 @@ import type {
BrowserServerContribution,
ParentProjectBrowser,
} from '../types/browser'
import type { ResolvedConfig } from '../types/config'
import type { ResolvedConfig, ResolvedProjectEntry } from '../types/config'
import { createViteServer } from '../vite'
export interface BrowserContributionHolder {
@@ -111,6 +111,7 @@ export async function createClusterServer(
vitest: Vitest,
viteConfig: ResolvedViteConfig,
config: ResolvedConfig,
children: readonly ResolvedProjectEntry[],
): Promise<{ server: ViteDevServer; parent?: ParentProjectBrowser }> {
const contribution = config._browserContribution
@@ -125,6 +126,23 @@ export async function createClusterServer(
const parent = contribution.createParent({ config, vitest })
contribution.parent = parent
// Start browser launches now so their latency overlaps Vite server creation.
// Entries that cannot run browser tests are skipped because they will never
// initialize a provider that could adopt and close the prepared browser.
for (const child of children) {
if (
child.hidden
|| child.hasTestFiles === false
|| (child.projectConfig.typecheck.enabled && child.projectConfig.typecheck.only)
) {
continue
}
// The Vite server is shared, but each child carries its own resolved
// provider and browser options, so it must be prewarmed independently.
const projectConfig = child.projectConfig
projectConfig.browser.provider?.prewarm?.({ config: projectConfig, vitest })
}
const server = await createViteServer(viteConfig)
await server.listen(config.api.port)
contribution.setupRpc(parent)
@@ -202,14 +202,15 @@ async function applyBrowserOptimizeDeps(
harness: PluginHarness,
entries: ResolvedProjectEntry[],
): Promise<void> {
const groups = new Map<ResolvedViteConfig, ResolvedConfig[]>()
for (const { viteConfig, projectConfig } of entries) {
const groups = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
for (const entry of entries) {
const { viteConfig } = entry
let group = groups.get(viteConfig)
if (!group) {
group = []
groups.set(viteConfig, group)
}
group.push(projectConfig)
group.push(entry)
}
// Most projects in a group share identical glob inputs (the `dir`/`root` is
@@ -228,12 +229,16 @@ async function applyBrowserOptimizeDeps(
}
await Promise.all(
Array.from(groups, async ([viteConfig, projectConfigs]) => {
Array.from(groups, async ([viteConfig, projectEntries]) => {
const projectConfigs = projectEntries.map(entry => entry.projectConfig)
const contribution = projectConfigs.find(config => config._browserContribution)?._browserContribution
if (!contribution) {
return
}
const fileLists = await Promise.all(projectConfigs.map(globTestFiles))
projectEntries.forEach((entry, index) => {
entry.hasTestFiles = fileLists[index].length > 0
})
const testFiles = [...new Set(fileLists.flat())]
const optimizeDeps = await contribution.resolveOptimizeDeps(projectConfigs, testFiles, harness)
// the browser runs in the `client` environment, but Vite's dep scanner
@@ -453,9 +458,9 @@ function expandBrowserInstancesInEntries(
for (const entry of browserEntries) {
const { projectConfig, viteConfig } = entry
const instances = projectConfig.browser.instances ?? []
const parentName = projectConfig.name
const instances = projectConfig.browser.instances ?? []
if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) {
continue
}
@@ -916,6 +921,12 @@ export async function attachProjectsFromEntries(
// provider). Siblings (browser instance variants, benchmark variants) share
// these resources by linking to the primary via `_parent`.
const primaryByViteConfig = new Map<ResolvedViteConfig, TestProject>()
const childrenByViteConfig = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
for (const entry of entries) {
const children = childrenByViteConfig.get(entry.viteConfig) ?? []
children.push(entry)
childrenByViteConfig.set(entry.viteConfig, children)
}
// The root Vite config can also serve as a project's `viteConfig` — either
// the default no-`projects` case or browser/benchmark variants of it.
@@ -963,7 +974,8 @@ export async function attachProjectsFromEntries(
// Workspace project with its own `viteConfig`: own a fresh Vite server. For
// a browser cluster this is the single server shared by `project.vite` and
// `project.browser.vite`.
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig)
const children = childrenByViteConfig.get(viteConfig) ?? []
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig, children)
const project = new TestProject(vitest, server, viteConfig, projectConfig)
project._initializeRunners(server)
if (parent) {
+10
View File
@@ -24,6 +24,16 @@ export interface BrowserProviderOption<Options extends object = object> {
name: string
supportedBrowser?: ReadonlyArray<string>
options: Options
/**
* Called once for every resolved browser project right before its shared
* Vite server is created, so the provider can start preparing the browser
* (e.g. launching it) concurrently. Optional, fire-and-forget: errors must
* surface through the normal provider flow.
*/
prewarm?: (ctx: {
config: ResolvedConfig
vitest: Vitest
}) => void
providerFactory: (project: TestProject) => BrowserProvider
serverFactory: BrowserServerFactory
}
+8
View File
@@ -1298,6 +1298,14 @@ export interface ResolvedConfig
export interface ResolvedProjectEntry {
viteConfig: ResolvedViteConfig
projectConfig: ResolvedConfig
/**
* Whether test files were found while resolving browser dependencies. This
* early result is used only to decide whether prewarming is useful; runtime
* discovery still globs after plugins have configured the server.
*
* @internal
*/
hasTestFiles?: boolean
/**
* When set, this entry exists only so browser-instance siblings can attach
* to a parent that owns the Vite server and (later) the browser provider.
+133
View File
@@ -0,0 +1,133 @@
import type { BrowserProviderOption } from 'vitest/node'
import { expect, test } from 'vitest'
import { runInlineTests } from '../../test-utils'
import { instances, provider, runInlineBrowserTests } from './utils'
function spyOnPrewarm() {
const prewarmed: (string | undefined)[] = []
const spyProvider: BrowserProviderOption = {
...provider,
prewarm(ctx) {
prewarmed.push(ctx.config.name)
provider.prewarm?.(ctx)
},
}
// config resolution assigns `name` on the instance objects,
// so the shared settings array cannot be reused between runs
const freshInstances = instances.map(instance => ({ ...instance }))
return { prewarmed, spyProvider, freshInstances }
}
const basicTest = `
import { test } from 'vitest'
test('works', () => {})
`
test('prewarm receives only the instances matching the --project filter', async () => {
const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm()
const target = instances[0].browser!
const result = await runInlineBrowserTests({
'basic.test.ts': basicTest,
}, {
project: [target],
browser: { provider: spyProvider, instances: freshInstances },
})
expect(result.stderr).toBe('')
expect(prewarmed).toEqual([target])
})
test('prewarm receives only the matching instances of a workspace project', async () => {
const { prewarmed, spyProvider, freshInstances } = spyOnPrewarm()
const target = `browser (${instances[0].browser})`
const { stderr } = await runInlineTests({
'basic.test.ts': basicTest,
}, {
watch: false,
reporters: 'none',
project: [target],
projects: [
{
test: {
name: 'browser',
browser: {
enabled: true,
provider: spyProvider,
instances: freshInstances,
headless: true,
},
},
},
],
})
expect(stderr).toBe('')
expect(prewarmed).toEqual([target])
})
test('prewarm uses the provider from the resolved instance project', async () => {
const prewarmed: { browser: string | undefined; name: string }[] = []
const instanceProvider: BrowserProviderOption = {
...provider,
prewarm(ctx) {
prewarmed.push({
browser: ctx.config.browser.name,
name: ctx.config.name,
})
provider.prewarm?.(ctx)
},
}
const target = instances[0].browser!
const freshInstances = instances.map((instance, index) => ({
...instance,
provider: index === 0 ? instanceProvider : undefined,
}))
const result = await runInlineBrowserTests({
'basic.test.ts': basicTest,
}, {
project: [target],
browser: { provider: undefined, instances: freshInstances },
})
expect(result.stderr).toBe('')
expect(prewarmed).toEqual([{ browser: target, name: target }])
})
test('does not prewarm a project without test files', async () => {
const { prewarmed, spyProvider } = spyOnPrewarm()
let projectNames: string[] = []
const browser = instances[0].browser!
const result = await runInlineBrowserTests({
'basic.test.ts': basicTest,
}, {
browser: {
provider: spyProvider,
instances: [
{ browser, name: 'with tests', include: ['basic.test.ts'] },
{ browser, name: 'without tests', include: ['missing.test.ts'] },
],
},
$viteConfig: {
plugins: [
{
name: 'capture-projects',
configureVitest({ project, vitest }) {
projectNames = vitest.projects.map(project => project.name)
if (project.name === 'without tests') {
project.config.include = ['basic.test.ts']
}
},
},
],
},
})
expect(result.stderr).toBe('')
expect(prewarmed).toEqual(['with tests'])
expect(projectNames).toEqual(['with tests', 'without tests'])
expect(result.ctx!.state.getFiles().map(file => file.projectName).sort()).toEqual(['with tests', 'without tests'])
})