Skip to content

Commit c17677a

Browse files
authored
perf(browser): prewarm the browser while the Vite server starts (#10727)
1 parent 86c700e commit c17677a

8 files changed

Lines changed: 341 additions & 32 deletions

File tree

packages/browser-playwright/src/playwright.ts

Lines changed: 149 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,136 @@ export function playwright(options: PlaywrightProviderOptions = {}): BrowserProv
9696
name: 'playwright',
9797
supportedBrowser: playwrightBrowsers,
9898
options,
99+
prewarm(ctx) {
100+
prewarmBrowser(ctx, options)
101+
},
99102
providerFactory(project) {
100103
return new PlaywrightBrowserProvider(project, options)
101104
},
102105
})
103106
}
104107

108+
interface WarmBrowser {
109+
promise: Promise<Browser>
110+
launchOptionsJson: string
111+
pending: Set<WarmBrowser>
112+
}
113+
114+
// the subset of `TestProject` the launch-option resolution needs; `prewarm`
115+
// runs before the project exists and receives the same shape
116+
interface LaunchContext {
117+
config: TestProject['config']
118+
vitest: TestProject['vitest']
119+
}
120+
121+
// The resolved config object is passed unchanged to the eventual TestProject,
122+
// so it identifies the browser this project can adopt.
123+
const warmBrowsers = new WeakMap<LaunchContext['config'], WarmBrowser>()
124+
const pendingWarmBrowsers = new WeakMap<LaunchContext['vitest'], Set<WarmBrowser>>()
125+
126+
// starts importing playwright and launching the browser while the node side
127+
// is still creating the vite server, so the launch latency overlaps it. The
128+
// launch options are resolved by the same code as the real launch — if they
129+
// still differ by the time the provider opens the browser, the warm instance
130+
// is discarded, so this is always safe
131+
function prewarmBrowser(project: LaunchContext, options: PlaywrightProviderOptions): void {
132+
const browserName = project.config.browser.name
133+
if (
134+
options.connectOptions
135+
|| options.persistentContext
136+
// don't speculate on debugging flows
137+
|| project.vitest.config.inspector.enabled
138+
) {
139+
return
140+
}
141+
if (!browserName || !(playwrightBrowsers as readonly string[]).includes(browserName)) {
142+
return
143+
}
144+
if (warmBrowsers.has(project.config)) {
145+
return
146+
}
147+
let pending = pendingWarmBrowsers.get(project.vitest)
148+
if (!pending) {
149+
const pendingBrowsers = new Set<WarmBrowser>()
150+
pending = pendingBrowsers
151+
pendingWarmBrowsers.set(project.vitest, pendingBrowsers)
152+
// Browsers whose projects never initialize a provider (they have no test
153+
// files to run) are cleaned up when Vitest closes.
154+
project.vitest.onClose(() => closeWarmBrowsers(pendingBrowsers))
155+
}
156+
const launchOptions = resolveLaunchOptions(project.config.browser, project.vitest.config.inspector, options, browserName)
157+
const entry: WarmBrowser = {
158+
launchOptionsJson: JSON.stringify(launchOptions),
159+
pending,
160+
promise: (async () => {
161+
debug?.('[%s] prewarming the browser', browserName)
162+
const playwright = await import('playwright')
163+
return playwright[browserName as PlaywrightBrowser].launch(launchOptions)
164+
})(),
165+
}
166+
// if the warm launch fails, drop it so the real launch retries
167+
// and surfaces the error through the normal path
168+
entry.promise.catch(() => {
169+
if (warmBrowsers.get(project.config) === entry) {
170+
warmBrowsers.delete(project.config)
171+
entry.pending.delete(entry)
172+
}
173+
})
174+
pending.add(entry)
175+
warmBrowsers.set(project.config, entry)
176+
}
177+
178+
function takeWarmBrowser(config: LaunchContext['config']): WarmBrowser | undefined {
179+
const warm = warmBrowsers.get(config)
180+
if (warm) {
181+
warmBrowsers.delete(config)
182+
warm.pending.delete(warm)
183+
}
184+
return warm
185+
}
186+
187+
async function closeWarmBrowsers(pending: Set<WarmBrowser>): Promise<void> {
188+
const closing = Array.from(pending, warm => warm.promise.then(browser => browser.close()).catch(() => {}))
189+
pending.clear()
190+
await Promise.all(closing)
191+
}
192+
193+
function resolveLaunchOptions(
194+
browser: TestProject['config']['browser'],
195+
inspector: TestProject['vitest']['config']['inspector'],
196+
providerOptions: PlaywrightProviderOptions,
197+
browserName: string,
198+
): LaunchOptions {
199+
const launchOptions: LaunchOptions = {
200+
...providerOptions.launchOptions,
201+
headless: browser.headless,
202+
}
203+
204+
if (typeof browser.trace === 'object' && browser.trace.tracesDir) {
205+
launchOptions.tracesDir = browser.trace.tracesDir
206+
}
207+
208+
if (inspector.enabled) {
209+
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
210+
const port = inspector.port || 9229
211+
212+
launchOptions.args ||= []
213+
launchOptions.args.push(`--remote-debugging-port=${port}`)
214+
}
215+
216+
// start Vitest UI maximized only on supported browsers
217+
if (browser.ui && browserName === 'chromium') {
218+
if (!launchOptions.args) {
219+
launchOptions.args = []
220+
}
221+
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
222+
launchOptions.args.push('--start-maximized')
223+
}
224+
}
225+
226+
return launchOptions
227+
}
228+
105229
export class PlaywrightBrowserProvider implements BrowserProvider {
106230
public name = 'playwright' as const
107231
public supportsParallelism = true
@@ -167,44 +291,26 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
167291
}
168292

169293
this.browserPromise = (async () => {
170-
const options = this.project.config.browser
171-
172294
const playwright = await import('playwright')
173295

174-
const launchOptions: LaunchOptions = {
175-
...this.options.launchOptions,
176-
headless: options.headless,
177-
}
178-
179-
if (typeof options.trace === 'object' && options.trace.tracesDir) {
180-
launchOptions.tracesDir = options.trace?.tracesDir
181-
}
296+
const launchOptions = resolveLaunchOptions(
297+
this.project.config.browser,
298+
this.project.vitest.config.inspector,
299+
this.options,
300+
this.browserName,
301+
)
182302

183303
const inspector = this.project.vitest.config.inspector
184304
if (inspector.enabled) {
185-
// NodeJS equivalent defaults: https://nodejs.org/en/learn/getting-started/debugging#enable-inspector
186305
const port = inspector.port || 9229
187306
const host = inspector.host || '127.0.0.1'
188307

189-
launchOptions.args ||= []
190-
launchOptions.args.push(`--remote-debugging-port=${port}`)
191-
192308
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
193309
this.project.vitest.logger.warn(`Custom inspector host "${host}" will be ignored. Chromium only allows remote debugging on localhost.`)
194310
}
195311
this.project.vitest.logger.log(`Debugger listening on ws://127.0.0.1:${port}`)
196312
}
197313

198-
// start Vitest UI maximized only on supported browsers
199-
if (this.project.config.browser.ui && this.browserName === 'chromium') {
200-
if (!launchOptions.args) {
201-
launchOptions.args = []
202-
}
203-
if (!launchOptions.args.includes('--start-maximized') && !launchOptions.args.includes('--start-fullscreen')) {
204-
launchOptions.args.push('--start-maximized')
205-
}
206-
}
207-
208314
debug?.('[%s] initializing the browser with launch options: %O', this.browserName, launchOptions)
209315

210316
if (this.options.connectOptions) {
@@ -250,6 +356,20 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
250356
this.browser = this.persistentContext.browser()!
251357
}
252358
else {
359+
const warm = takeWarmBrowser(this.project.config)
360+
if (warm && warm.launchOptionsJson === JSON.stringify(launchOptions)) {
361+
const browser = await warm.promise.catch(() => null)
362+
if (browser?.isConnected()) {
363+
debug?.('[%s] adopting the prewarmed browser', this.browserName)
364+
this.browser = browser
365+
this.browserPromise = null
366+
return this.browser
367+
}
368+
}
369+
else if (warm) {
370+
debug?.('[%s] discarding the prewarmed browser, launch options changed', this.browserName)
371+
void warm.promise.then(browser => browser.close()).catch(() => {})
372+
}
253373
this.browser = await playwright[this.browserName].launch(launchOptions)
254374
}
255375
this.browserPromise = null
@@ -553,6 +673,11 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
553673

554674
debug?.('[%s] closing provider', this.browserName)
555675
this.closing = true
676+
// a prewarmed browser that was never adopted must not outlive the provider
677+
const warm = takeWarmBrowser(this.project.config)
678+
if (warm) {
679+
void warm.promise.then(browser => browser.close()).catch(() => {})
680+
}
556681
if (this.browserPromise) {
557682
await this.browserPromise
558683
this.browserPromise = null

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export function resolveTestConfig(
416416
+ `Use a single provider for the project, or move the instances into separate projects.`,
417417
)
418418
}
419+
browser.provider ??= browser.instances.find(instance => instance.provider)?.provider
419420

420421
// use `chromium` by default when the preview provider is specified
421422
// for a smoother experience. if chromium is not available, it will

packages/vitest/src/node/core.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,11 @@ export class Vitest {
283283
*/
284284
async _attachRootServer(): Promise<void> {
285285
const resolved = this.config
286+
const children = resolved.resolvedProjects
287+
.filter(entry => entry.viteConfig === this.viteConfig)
286288
// For a root-level browser config (no `projects`) this builds the single
287289
// browser server; otherwise it just creates the Vite server.
288-
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved)
290+
const { server, parent } = await createClusterServer(this, this.viteConfig, resolved, children)
289291
this.vite = server
290292
this._rootBrowserParent = parent
291293

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
BrowserServerContribution,
1010
ParentProjectBrowser,
1111
} from '../types/browser'
12-
import type { ResolvedConfig } from '../types/config'
12+
import type { ResolvedConfig, ResolvedProjectEntry } from '../types/config'
1313
import { createViteServer } from '../vite'
1414

1515
export interface BrowserContributionHolder {
@@ -111,6 +111,7 @@ export async function createClusterServer(
111111
vitest: Vitest,
112112
viteConfig: ResolvedViteConfig,
113113
config: ResolvedConfig,
114+
children: readonly ResolvedProjectEntry[],
114115
): Promise<{ server: ViteDevServer; parent?: ParentProjectBrowser }> {
115116
const contribution = config._browserContribution
116117

@@ -125,6 +126,23 @@ export async function createClusterServer(
125126
const parent = contribution.createParent({ config, vitest })
126127
contribution.parent = parent
127128

129+
// Start browser launches now so their latency overlaps Vite server creation.
130+
// Entries that cannot run browser tests are skipped because they will never
131+
// initialize a provider that could adopt and close the prepared browser.
132+
for (const child of children) {
133+
if (
134+
child.hidden
135+
|| child.hasTestFiles === false
136+
|| (child.projectConfig.typecheck.enabled && child.projectConfig.typecheck.only)
137+
) {
138+
continue
139+
}
140+
// The Vite server is shared, but each child carries its own resolved
141+
// provider and browser options, so it must be prewarmed independently.
142+
const projectConfig = child.projectConfig
143+
projectConfig.browser.provider?.prewarm?.({ config: projectConfig, vitest })
144+
}
145+
128146
const server = await createViteServer(viteConfig)
129147
await server.listen(config.api.port)
130148
contribution.setupRpc(parent)

packages/vitest/src/node/projects/resolveProjects.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,15 @@ async function applyBrowserOptimizeDeps(
202202
harness: PluginHarness,
203203
entries: ResolvedProjectEntry[],
204204
): Promise<void> {
205-
const groups = new Map<ResolvedViteConfig, ResolvedConfig[]>()
206-
for (const { viteConfig, projectConfig } of entries) {
205+
const groups = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
206+
for (const entry of entries) {
207+
const { viteConfig } = entry
207208
let group = groups.get(viteConfig)
208209
if (!group) {
209210
group = []
210211
groups.set(viteConfig, group)
211212
}
212-
group.push(projectConfig)
213+
group.push(entry)
213214
}
214215

215216
// Most projects in a group share identical glob inputs (the `dir`/`root` is
@@ -228,12 +229,16 @@ async function applyBrowserOptimizeDeps(
228229
}
229230

230231
await Promise.all(
231-
Array.from(groups, async ([viteConfig, projectConfigs]) => {
232+
Array.from(groups, async ([viteConfig, projectEntries]) => {
233+
const projectConfigs = projectEntries.map(entry => entry.projectConfig)
232234
const contribution = projectConfigs.find(config => config._browserContribution)?._browserContribution
233235
if (!contribution) {
234236
return
235237
}
236238
const fileLists = await Promise.all(projectConfigs.map(globTestFiles))
239+
projectEntries.forEach((entry, index) => {
240+
entry.hasTestFiles = fileLists[index].length > 0
241+
})
237242
const testFiles = [...new Set(fileLists.flat())]
238243
const optimizeDeps = await contribution.resolveOptimizeDeps(projectConfigs, testFiles, harness)
239244
// the browser runs in the `client` environment, but Vite's dep scanner
@@ -453,9 +458,9 @@ function expandBrowserInstancesInEntries(
453458

454459
for (const entry of browserEntries) {
455460
const { projectConfig, viteConfig } = entry
456-
const instances = projectConfig.browser.instances ?? []
457461
const parentName = projectConfig.name
458462

463+
const instances = projectConfig.browser.instances ?? []
459464
if (instances.length === 0 || isExcludedByProjectFilter(globalConfig.project, parentName)) {
460465
continue
461466
}
@@ -916,6 +921,12 @@ export async function attachProjectsFromEntries(
916921
// provider). Siblings (browser instance variants, benchmark variants) share
917922
// these resources by linking to the primary via `_parent`.
918923
const primaryByViteConfig = new Map<ResolvedViteConfig, TestProject>()
924+
const childrenByViteConfig = new Map<ResolvedViteConfig, ResolvedProjectEntry[]>()
925+
for (const entry of entries) {
926+
const children = childrenByViteConfig.get(entry.viteConfig) ?? []
927+
children.push(entry)
928+
childrenByViteConfig.set(entry.viteConfig, children)
929+
}
919930

920931
// The root Vite config can also serve as a project's `viteConfig` — either
921932
// the default no-`projects` case or browser/benchmark variants of it.
@@ -963,7 +974,8 @@ export async function attachProjectsFromEntries(
963974
// Workspace project with its own `viteConfig`: own a fresh Vite server. For
964975
// a browser cluster this is the single server shared by `project.vite` and
965976
// `project.browser.vite`.
966-
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig)
977+
const children = childrenByViteConfig.get(viteConfig) ?? []
978+
const { server, parent } = await createClusterServer(vitest, viteConfig, projectConfig, children)
967979
const project = new TestProject(vitest, server, viteConfig, projectConfig)
968980
project._initializeRunners(server)
969981
if (parent) {

packages/vitest/src/node/types/browser.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ export interface BrowserProviderOption<Options extends object = object> {
2424
name: string
2525
supportedBrowser?: ReadonlyArray<string>
2626
options: Options
27+
/**
28+
* Called once for every resolved browser project right before its shared
29+
* Vite server is created, so the provider can start preparing the browser
30+
* (e.g. launching it) concurrently. Optional, fire-and-forget: errors must
31+
* surface through the normal provider flow.
32+
*/
33+
prewarm?: (ctx: {
34+
config: ResolvedConfig
35+
vitest: Vitest
36+
}) => void
2737
providerFactory: (project: TestProject) => BrowserProvider
2838
serverFactory: BrowserServerFactory
2939
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,14 @@ export interface ResolvedConfig
12981298
export interface ResolvedProjectEntry {
12991299
viteConfig: ResolvedViteConfig
13001300
projectConfig: ResolvedConfig
1301+
/**
1302+
* Whether test files were found while resolving browser dependencies. This
1303+
* early result is used only to decide whether prewarming is useful; runtime
1304+
* discovery still globs after plugins have configured the server.
1305+
*
1306+
* @internal
1307+
*/
1308+
hasTestFiles?: boolean
13011309
/**
13021310
* When set, this entry exists only so browser-instance siblings can attach
13031311
* to a parent that owns the Vite server and (later) the browser provider.

0 commit comments

Comments
 (0)