diff --git a/packages/nuxt-cli/src/utils/build-progress.ts b/packages/nuxt-cli/src/utils/build-progress.ts index 09c9244bb..9421a47c9 100644 --- a/packages/nuxt-cli/src/utils/build-progress.ts +++ b/packages/nuxt-cli/src/utils/build-progress.ts @@ -22,7 +22,6 @@ const HOOK_PHASES: Record = { 'builder:generateApp': 'app', 'prepare:types': 'types', 'build:before': 'client', - 'nitro:build:before': 'nitro', } /** Nitro builds on its own hooks, which Nuxt's never see. */ @@ -45,10 +44,14 @@ interface HookableLike { } interface NuxtLike { - options?: { experimental?: { viteEnvironmentApi?: boolean } } hooks?: HookableLike } +interface VitePluginContext { + /** Missing before Vite 6. */ + environment?: { name: string } +} + /** * Tracks how far a production build has got, so the same transient phase line * `nuxt dev` shows can report `nuxt build`, and the completion summary can list @@ -58,11 +61,10 @@ export class BuildProgress extends PhaseTimeline { #message = BUILD_PHASES[0]!.message #finished = false /** - * With the Vite environment API a single build covers both environments, so - * the client and server configs resolve up front and cannot mark the boundary - * between them. + * Vite can resolve every config, and start Nitro's build, before it bundles + * anything, so with Vite only its bundle hooks mark the phases. */ - #splitBundles = true + #bundledByVite = false constructor() { super(BUILD_PHASES) @@ -110,16 +112,19 @@ export class BuildProgress extends PhaseTimeline { * the project's own build rather than a schedule guessed by the CLI. */ attachNuxt(nuxt: NuxtLike): void { - this.#splitBundles = !nuxt.options?.experimental?.viteEnvironmentApi nuxt.hooks?.beforeEach?.(({ name, args }) => { const phase = HOOK_PHASES[name] if (phase) { - this.setPhase(phase, phase === 'client' && !this.#splitBundles ? 'Bundling app' : undefined) + this.setPhase(phase) return } - if (name === 'vite:configResolved' && this.#splitBundles) { - const ctx = args?.[1] as { isServer?: boolean } | undefined - this.setPhase(ctx?.isServer ? 'server' : 'client') + if (name === 'vite:extend') { + this.#bundledByVite = true + const { config } = args![0] as { config: { plugins?: unknown[] } } + ;(config.plugins ||= []).push(this.#vitePlugin()) + } + else if (name === 'nitro:build:before' && !this.#bundledByVite) { + this.setPhase('nitro') } else if (name === 'webpack:compile') { const ctx = args?.[0] as { name?: string } | undefined @@ -131,6 +136,27 @@ export class BuildProgress extends PhaseTimeline { }) } + #vitePlugin() { + const setPhase = (id: string) => this.setPhase(id) + let ssr = false + const isServer = (ctx: VitePluginContext) => (ctx.environment?.name ?? (ssr ? 'ssr' : 'client')) === 'ssr' + return { + name: 'nuxt-cli:build-progress', + configResolved(config: { build?: { ssr?: unknown } }) { + ssr = !!config.build?.ssr + }, + buildStart(this: VitePluginContext) { + setPhase(isServer(this) ? 'server' : 'client') + }, + // Nitro copies public assets and prerenders before its own bundle starts. + closeBundle(this: VitePluginContext) { + if (isServer(this)) { + setPhase('nitro') + } + }, + } + } + #attachNitro(hooks: HookableLike | undefined): void { hooks?.beforeEach?.(({ name, args }) => { // Nitro's hooks also run while Nuxt is still bundling the app, and those diff --git a/packages/nuxt-cli/test/unit/build-progress.spec.ts b/packages/nuxt-cli/test/unit/build-progress.spec.ts index 8aa216973..3f8d1d634 100644 --- a/packages/nuxt-cli/test/unit/build-progress.spec.ts +++ b/packages/nuxt-cli/test/unit/build-progress.spec.ts @@ -6,12 +6,17 @@ import { BuildProgress } from '../../src/utils/build-progress' type HookHandler = (event: { name: string, args?: unknown[] }) => void -function nuxtStub(options: { viteEnvironmentApi?: boolean } = {}) { +interface VitePlugin { + configResolved: (config: { build?: { ssr?: unknown } }) => void + buildStart: (this: { environment?: { name: string } }) => void + closeBundle: (this: { environment?: { name: string } }) => void +} + +function nuxtStub() { const handlers: HookHandler[] = [] const nitroHandlers: HookHandler[] = [] return { nuxt: { - options: { experimental: { viteEnvironmentApi: options.viteEnvironmentApi } }, hooks: { beforeEach: (fn: HookHandler) => void handlers.push(fn) }, }, call(name: string, ...args: unknown[]) { @@ -19,6 +24,11 @@ function nuxtStub(options: { viteEnvironmentApi?: boolean } = {}) { handler({ name, args }) } }, + extendVite() { + const config: { plugins?: VitePlugin[] } = {} + this.call('vite:extend', { config }) + return config.plugins![0]! + }, nitro: { hooks: { beforeEach: (fn: HookHandler) => void nitroHandlers.push(fn) } }, callNitro(name: string, ...args: unknown[]) { for (const handler of nitroHandlers) { @@ -45,8 +55,13 @@ describe('build progress', () => { stub.call('builder:generateApp') stub.call('prepare:types') stub.call('build:before') - stub.call('vite:configResolved', {}, { isClient: true, isServer: false }) - stub.call('vite:configResolved', {}, { isClient: false, isServer: true }) + const plugin = stub.extendVite() + plugin.configResolved({ build: {} }) + plugin.buildStart.call({}) + plugin.closeBundle.call({}) + plugin.configResolved({ build: { ssr: true } }) + plugin.buildStart.call({}) + plugin.closeBundle.call({}) stub.call('nitro:build:before', {}) progress.finish() @@ -62,17 +77,27 @@ describe('build progress', () => { expect(progress.timings.map(timing => timing.phase)).toEqual(['config', 'modules', 'app', 'types', 'client', 'server', 'nitro']) }) - it('should not split the bundle phase when a single build covers both environments', () => { + it('should time each bundle when Vite builds every environment in one run', () => { const progress = new BuildProgress() - const stub = nuxtStub({ viteEnvironmentApi: true }) + const stub = nuxtStub() progress.attachNuxt(stub.nuxt) const seen = messages(progress) stub.call('build:before') + const plugin = stub.extendVite() stub.call('vite:configResolved', {}, { isClient: true, isServer: false }) stub.call('vite:configResolved', {}, { isClient: false, isServer: true }) + stub.call('nitro:build:before', {}) + expect(seen).toEqual(['Loading Nuxt config', 'Bundling client']) + + for (const name of ['client', 'ssr', 'nitro']) { + plugin.buildStart.call({ environment: { name } }) + plugin.closeBundle.call({ environment: { name } }) + } + progress.finish() - expect(seen).toEqual(['Loading Nuxt config', 'Bundling app']) + expect(seen).toEqual(['Loading Nuxt config', 'Bundling client', 'Bundling server', 'Building Nitro server']) + expect(progress.timings.map(timing => timing.phase)).toEqual(['config', 'client', 'server', 'nitro']) }) it('should name what the Nitro phase is doing', () => {