diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts index d424b4c0c..a30fa85c9 100644 --- a/packages/vite-plugin/src/build-start.ts +++ b/packages/vite-plugin/src/build-start.ts @@ -38,7 +38,7 @@ export async function runBuildStart( // The hot path classifies file events by the Session's own scan policy. ctx.excludeMatcher = session.scanConfig.excludeMatcher; ctx.extensionsSet = session.scanConfig.extensionsSet; - // An earlier environment's closeBundle closed it: it joins its build again. + // An earlier build's end retired it: it joins its build again. session.reattach(); session.hostResolve = resolveSpecifier; // A build fails on an aborted parse. A dev server starts through it with diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index 76e3e9075..36e35a70c 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -90,6 +90,19 @@ const NO_SHEETS: AssetSheets = { componentCss: '', }; +/** One environment's build, as Vite hands it to a hook: the object is its + * identity, the name its key. */ +export interface BuildingEnvironment { + readonly name: string; +} + +/** An environment's open build, and the promise its end settles. */ +interface OpenBuild { + environment: BuildingEnvironment; + ended: Promise; + end(): void; +} + export class PluginContext { readonly options: AnimusExtractOptions; readonly verbose: boolean; @@ -439,6 +452,43 @@ export class PluginContext { this.applyAssetSubstitutions(); } + /** The environment a host without environments builds as. */ + readonly singleEnvironment: BuildingEnvironment = { name: 'build' }; + + /** Each environment build that started and has not ended, by name. A + * custom `buildApp` builds a shared plugin's environments in parallel, so + * the context's build retires with the last of them, not the first. */ + private readonly openBuilds = new Map(); + + /** Opens `environment`'s build, after any open build under its name has + * ended: a builder reused while an earlier build still runs, or a second + * environment under one name, builds after it rather than beside it. + * Each build then ends its own generation once, in `buildEnd`. */ + async openBuild(environment: BuildingEnvironment): Promise { + for (;;) { + const open = this.openBuilds.get(environment.name); + if (open === undefined) break; + await open.ended; + } + let end = () => {}; + const ended = new Promise((settle) => { + end = settle; + }); + this.openBuilds.set(environment.name, { environment, ended, end }); + } + + /** Ends `environment`'s open build; whether that left none open. Vite + * ends each build once, in `buildEnd`, and a dev server ends only the + * environments that started, so only the build that opened an entry + * ends it. */ + endBuild(environment: BuildingEnvironment): boolean { + const open = this.openBuilds.get(environment.name); + if (open === undefined || open.environment !== environment) return false; + this.openBuilds.delete(environment.name); + open.end(); + return this.openBuilds.size === 0; + } + /** Retires this context's build when Vite closes it: the Session leaves * the registry (with its manifest and watch state) and the engine handle * is released, injected engine or not. The next build start rejoins. */ diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index d97206c63..6525032e1 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -206,14 +206,18 @@ export function animusExtract(options: AnimusExtractOptions): Plugin { applyResolvedConfig(ctx, config); }, - // The end of a build, and of a dev server, which Vite closes the same - // way: the context's Session and engine are retired with it. - closeBundle() { + // The end of a build, and of a dev server, which Vite ends the same way: + // the last open environment build retires the context's Session and + // engine. buildEnd runs once per build, failed ones included; closeBundle + // can run twice for a failed one. + buildEnd() { + if (!ctx.endBuild(this.environment ?? ctx.singleEnvironment)) return; cancelSystemReload(ctx); ctx.close(); }, async buildStart() { + await ctx.openBuild(this.environment ?? ctx.singleEnvironment); await runBuildStart( ctx, async (specifier, importer) => { diff --git a/packages/vite-plugin/tests/dev-server/build-app.test.ts b/packages/vite-plugin/tests/dev-server/build-app.test.ts new file mode 100644 index 000000000..7c4718481 --- /dev/null +++ b/packages/vite-plugin/tests/dev-server/build-app.test.ts @@ -0,0 +1,196 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { BuildEnvironment, createBuilder } from 'vite-plus'; +import { describe, expect, it } from 'vitest'; + +import { animusExtract } from '../../src/index'; +import { createDevFixture } from './fixture'; + +import type { ViteBuilder } from 'vite-plus'; + +/** Fails the client build while `failing.client` is set. */ +const failing = { client: false }; +const failClient = { + name: 'fail-client', + transform(this: { environment: { name: string } }) { + if (failing.client && this.environment.name === 'client') { + throw new Error('planned client failure'); + } + return null; + }, +}; + +/** Delays every SSR transform, so the client environment closes first. */ +const slowSsr = { + name: 'slow-ssr', + enforce: 'pre' as const, + async transform(this: { environment: { name: string } }) { + if (this.environment.name === 'ssr') { + await new Promise((settle) => setTimeout(settle, 300)); + } + return null; + }, +}; + +interface EnvironmentBuild { + build: { + outDir: string; + ssr?: true; + rollupOptions: { input: string }; + }; +} + +interface ParallelBuildConfig { + root: string; + configFile: false; + logLevel: 'silent'; + plugins: Array< + typeof slowSsr | typeof failClient | ReturnType + >; + environments: { client: EnvironmentBuild; ssr: EnvironmentBuild }; + builder: { buildApp(app: ViteBuilder): Promise }; +} + +type StartParallelBuild = ( + config: ParallelBuildConfig +) => Promise<{ buildApp(): Promise }>; + +function filesUnder(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? filesUnder(join(dir, entry.name)) + : [join(dir, entry.name)] + ); +} + +/** + * A custom `buildApp` builds a shared plugin's environments in parallel: the + * first to close must not retire the Session and engine the others still + * transform with. + */ +describe('environments built in parallel', () => { + it('the first environment to close leaves the others their Session', async () => { + const fixture = createDevFixture(); + try { + const input = join(fixture.root, 'src', 'main.ts'); + // SAFETY: vite-plus runs Vite's builder and consumes the same plugin + // hooks; only the nominal declarations differ, and tsc overflows on them. + const startParallelBuild = createBuilder as StartParallelBuild; + const builder = await startParallelBuild({ + root: fixture.root, + configFile: false, + logLevel: 'silent', + plugins: [slowSsr, animusExtract({ system: './src/ds.ts' })], + environments: { + client: { + build: { outDir: 'dist/client', rollupOptions: { input } }, + }, + ssr: { + build: { outDir: 'dist/ssr', ssr: true, rollupOptions: { input } }, + }, + }, + builder: { + async buildApp(app) { + await Promise.all([ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]); + }, + }, + }); + await builder.buildApp(); + + const ssrCode = filesUnder(join(fixture.root, 'dist', 'ssr')) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(file, 'utf-8')) + .join('\n'); + expect(ssrCode).toContain('createComponent'); + expect(ssrCode).not.toContain('.styles('); + } finally { + fixture.dispose(); + } + }); + + it('a builder reused while an earlier build still runs builds after it', async () => { + const fixture = createDevFixture(); + try { + // Every environment build either app starts, in order. + const builds: Array> = []; + const builder = await parallelBuilder(fixture.root, async (app) => { + const started = [ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]; + builds.push(...started); + await Promise.all(started); + }); + // The client fails at once; the slow SSR build is still running when + // the builder is reused. + failing.client = true; + await expect(builder.buildApp()).rejects.toThrow( + 'planned client failure' + ); + failing.client = false; + await builder.buildApp(); + + const outcomes = await Promise.allSettled(builds); + expect(outcomes.map((outcome) => outcome.status)).toEqual([ + 'rejected', + 'fulfilled', + 'fulfilled', + 'fulfilled', + ]); + } finally { + failing.client = false; + fixture.dispose(); + } + }); + + it('two environments under one name build one after the other', async () => { + const fixture = createDevFixture(); + try { + // A second, distinct environment object under the client's name. + const builder = await parallelBuilder(fixture.root, async (app) => { + const twin = new BuildEnvironment('client', app.config); + await twin.init(); + await Promise.all([ + app.build(app.environments.client), + app.build(twin), + ]); + }); + + await expect(builder.buildApp()).resolves.toBeUndefined(); + } finally { + fixture.dispose(); + } + }); +}); + +/** A builder whose app builds client and SSR in parallel, SSR slowly, + * unless `buildApp` builds otherwise. */ +function parallelBuilder( + root: string, + buildApp: (app: ViteBuilder) => Promise = async (app) => { + await Promise.all([ + app.build(app.environments.client), + app.build(app.environments.ssr), + ]); + } +) { + const input = join(root, 'src', 'main.ts'); + // SAFETY: as above. + const startParallelBuild = createBuilder as StartParallelBuild; + return startParallelBuild({ + root, + configFile: false, + logLevel: 'silent', + plugins: [slowSsr, failClient, animusExtract({ system: './src/ds.ts' })], + environments: { + client: { build: { outDir: 'dist/client', rollupOptions: { input } } }, + ssr: { + build: { outDir: 'dist/ssr', ssr: true, rollupOptions: { input } }, + }, + }, + builder: { buildApp }, + }); +}