diff --git a/packages/unplugin/src/core.ts b/packages/unplugin/src/core.ts index ce0809c99..666676437 100644 --- a/packages/unplugin/src/core.ts +++ b/packages/unplugin/src/core.ts @@ -247,6 +247,19 @@ function canResolve( return 'resolve' in context; } +/** One environment's build, as Vite hands it to a hook: the object is its + * identity, the name its key. */ +interface BuildingEnvironment { + readonly name: string; +} + +/** Whether a hook context names the environment it builds (Vite). */ +function hasEnvironment( + context: UnpluginBuildContext +): context is UnpluginBuildContext & { environment: BuildingEnvironment } { + return 'environment' in context; +} + /** Marks the esbuild resolve that `esbuild.setup` makes for itself. */ const KIT_SOURCE_RESOLVE = 'animus:kit-source-resolve'; @@ -536,6 +549,61 @@ export const unpluginFactory: UnpluginFactory< }); } + /** Each Rollup-family environment build that started and has not ended, + * by name, with the promise its end settles. A Vite buildApp can build + * environments in parallel on one plugin instance; they share one + * pipeline and one Session, retired by the last to end. A build of an + * environment whose build is open (a reused builder, or a second + * environment under one name) waits for that build's end, so each build + * ends only its own generation, once, in buildEnd. webpack-like hosts run + * one compile at a time and are not tracked. */ + const openBuilds = new Map< + string, + { environment: BuildingEnvironment; ended: Promise; end(): void } + >(); + const singleEnvironment: BuildingEnvironment = { name: 'build' }; + const tracksEnvironments = + meta.framework === 'rollup' || + meta.framework === 'rolldown' || + meta.framework === 'vite'; + + function environmentOf(context: UnpluginBuildContext): BuildingEnvironment { + return hasEnvironment(context) ? context.environment : singleEnvironment; + } + + /** Opens this hook's environment build once no build under its name is + * open; whether another environment's build is open, whose pipeline this + * build shares. */ + async function openEnvironmentBuild( + context: UnpluginBuildContext + ): Promise { + if (!tracksEnvironments) return false; + const environment = environmentOf(context); + for (;;) { + const open = openBuilds.get(environment.name); + if (open === undefined) break; + await open.ended; + } + let end = () => {}; + const ended = new Promise((settle) => { + end = settle; + }); + openBuilds.set(environment.name, { environment, ended, end }); + return openBuilds.size > 1; + } + + /** Ends this hook's environment build; whether none is left open. Only + * the build that opened an entry ends it. */ + function closeEnvironmentBuild(context: UnpluginBuildContext): boolean { + if (!tracksEnvironments) return true; + const environment = environmentOf(context); + const open = openBuilds.get(environment.name); + if (open === undefined || open.environment !== environment) return false; + openBuilds.delete(environment.name); + open.end(); + return openBuilds.size === 0; + } + async function joinPipeline(): Promise { await pipelineStarted; await state.pipeline; @@ -610,7 +678,10 @@ export const unpluginFactory: UnpluginFactory< async buildStart() { if (rollupLike) hostResolver = pluginResolver(this); try { - await startPipeline(); + // Another environment's build already runs this build's pipeline + // (a parallel Vite buildApp): share it rather than claim again. + if (await openEnvironmentBuild(this)) await joinPipeline(); + else await startPipeline(); } finally { registerWatchTargets(this); } @@ -747,8 +818,11 @@ export const unpluginFactory: UnpluginFactory< try { emitCssAsset(this); } finally { - disposeSessionDir(state); - releaseClaim(); + // The last environment's build retires the shared one. + if (closeEnvironmentBuild(this)) { + disposeSessionDir(state); + releaseClaim(); + } } }, diff --git a/packages/unplugin/tests/vite-parallel-build.test.ts b/packages/unplugin/tests/vite-parallel-build.test.ts new file mode 100644 index 000000000..9e2ab5e5a --- /dev/null +++ b/packages/unplugin/tests/vite-parallel-build.test.ts @@ -0,0 +1,214 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { createBuilder } from 'vite-plus'; +import { describe, expect, it } from 'vitest'; + +import { + createDevFixture, + INITIAL_BRAND_HEX, + themeSource, +} from '../../vite-plugin/tests/dev-server/fixture'; +import { animusUnplugin } from '../src/index'; + +import type { ViteBuilder } from 'vite-plus'; + +interface EnvironmentBuild { + build: { + outDir: string; + ssr?: true; + emitAssets?: true; + rollupOptions: { input: string }; + }; +} + +/** 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 ends 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 ParallelBuildConfig { + root: string; + configFile: false; + logLevel: 'silent'; + plugins: Array< + ReturnType | typeof failClient | typeof slowSsr + >; + 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 Vite `buildApp` builds environments in parallel on one plugin + * instance: they share one pipeline and one Session, never claiming the + * build twice. + */ +describe('unplugin under Vite with environments built in parallel', () => { + it('both environments build from one shared analysis', 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: [ + animusUnplugin.vite({ system: './src/ds.ts', root: fixture.root }), + ], + 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 keeps that build its assets', async () => { + const fixture = createDevFixture(); + try { + // A copied asset: an image scale value the Badge background reads. + fixture.write( + 'assets/badge.svg', + '\n' + ); + fixture.write( + 'src/theme.ts', + `${themeSource(INITIAL_BRAND_HEX) + .replace( + "import { createTheme } from '@animus-ui/system';", + "import { asset, createTheme } from '@animus-ui/system';" + ) + .replace( + ' .build();', + ` .addScale({ + name: 'images', + values: { badge: \`url("\${asset('./assets/badge.svg')}")\` }, + }) + .build();` + )}` + ); + fixture.write( + 'src/Badge.ts', + "import { ds } from './ds';\n\nexport const Badge = ds\n .styles({ backgroundImage: '{images.badge}' })\n .asElement('span');\n" + ); + fixture.write( + 'src/main.ts', + "import { Badge } from './Badge';\nimport { Button } from './Button';\n\nexport const roots = [Badge, Button];\n" + ); + const input = join(fixture.root, 'src', 'main.ts'); + // Every environment build either app starts, in order. + const builds: Array> = []; + // SAFETY: as above. + const startParallelBuild = createBuilder as StartParallelBuild; + const builder = await startParallelBuild({ + root: fixture.root, + configFile: false, + logLevel: 'silent', + plugins: [ + slowSsr, + failClient, + animusUnplugin.vite({ system: './src/ds.ts', root: fixture.root }), + ], + environments: { + client: { + build: { outDir: 'dist/client', rollupOptions: { input } }, + }, + // SSR emits assets too, so its stylesheet's asset must be there. + ssr: { + build: { + outDir: 'dist/ssr', + ssr: true, + emitAssets: true, + rollupOptions: { input }, + }, + }, + }, + builder: { + async buildApp(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 [, originalSsr] = await Promise.allSettled(builds); + expect(originalSsr.status).toBe('fulfilled'); + const output = + originalSsr.status === 'fulfilled' + ? JSON.stringify(originalSsr.value) + : ''; + expect(output).toMatch(/assets\/badge[^"]*\.svg/); + expect(output).toMatch(/"fileName":"[^"]*badge[^"]*\.svg"/); + } finally { + failing.client = false; + fixture.dispose(); + } + }); +});