From 64c95470173f670b3af4bfd8d5821007c5b49f5e Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:21:55 -0400 Subject: [PATCH 1/4] unplugin under Vite: environments built in parallel share one pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom Vite buildApp that built environments in parallel failed every time: each environment's buildStart made a new Session for the same build, and the second collided with the first's claim ("… started an analysis of build …, which … publishes"). Released: v0.1.25 had the same shape (a new Session per buildStart, a process-global claim). Under Vite, Rollup and Rolldown the plugin now tracks open environment builds by name: a buildStart while another environment's build is open joins the running pipeline, and buildEnd disposes and releases only when the last ends. As in vite-plugin (#395), a second build of an active environment and a second environment under an active name are refused, naming the environment. webpack-like hosts run one compile at a time and are not tracked. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/unplugin/src/core.ts | 79 +++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/unplugin/src/core.ts b/packages/unplugin/src/core.ts index 2b6592f48..d527cc0ea 100644 --- a/packages/unplugin/src/core.ts +++ b/packages/unplugin/src/core.ts @@ -245,6 +245,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'; @@ -488,6 +501,60 @@ export const unpluginFactory: UnpluginFactory< }); } + /** Each Rollup-family environment build that started and has not + * ended, by name. 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. Builds are tracked by environment, not by + * generation, so a second build of an active environment, or a second + * environment under an active name, is refused. webpack-like hosts run + * one compile at a time and are not tracked. */ + const openBuilds = new Map(); + const singleEnvironment: BuildingEnvironment = { name: 'build' }; + const tracksEnvironments = + meta.framework === 'rollup' || + meta.framework === 'rolldown' || + meta.framework === 'vite'; + + /** Opens this hook's environment build; whether another environment's + * build was already open. */ + function openEnvironmentBuild(context: UnpluginBuildContext): boolean { + if (!tracksEnvironments) return false; + const environment = hasEnvironment(context) + ? context.environment + : singleEnvironment; + const { name } = environment; + const open = openBuilds.get(name); + if (open === environment) { + throw new Error( + `[animus] a previous build of environment ${name} is still running ` + + `on this plugin instance; wait for it to finish before building ` + + `it again` + ); + } + if (open !== undefined) { + throw new Error( + `[animus] two environments named ${name} build on this plugin ` + + `instance at once; give each environment its own name` + ); + } + openBuilds.set(name, environment); + return openBuilds.size > 1; + } + + /** Ends this hook's environment build; whether none is left open. Only + * the environment that opened an entry removes it, since a refused + * build ends too. */ + function closeEnvironmentBuild(context: UnpluginBuildContext): boolean { + if (!tracksEnvironments) return true; + const environment = hasEnvironment(context) + ? context.environment + : singleEnvironment; + if (openBuilds.get(environment.name) === environment) { + openBuilds.delete(environment.name); + } + return openBuilds.size === 0; + } + async function joinPipeline(): Promise { await pipelineStarted; await state.pipeline; @@ -561,7 +628,10 @@ export const unpluginFactory: UnpluginFactory< async buildStart() { try { - await startPipeline(); + // Another environment's build already runs this build's pipeline + // (a parallel Vite buildApp): share it rather than claim again. + if (openEnvironmentBuild(this)) await joinPipeline(); + else await startPipeline(); } finally { registerWatchTargets(this); } @@ -698,8 +768,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(); + } } }, From c449922a4fff49f4814320697f2d84750056da85 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:21:55 -0400 Subject: [PATCH 2/4] Row: unplugin under Vite builds environments in parallel from one analysis A real Vite createBuilder over vite-plugin's dev fixture with animusUnplugin.vite, client and SSR built in parallel; SSR output must be extracted. Red on main a5d1f494 with the claim error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/vite-parallel-build.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/unplugin/tests/vite-parallel-build.test.ts 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..e86e44888 --- /dev/null +++ b/packages/unplugin/tests/vite-parallel-build.test.ts @@ -0,0 +1,85 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { createBuilder } from 'vite-plus'; +import { describe, expect, it } from 'vitest'; + +import { createDevFixture } 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; rollupOptions: { input: string } }; +} + +interface ParallelBuildConfig { + root: string; + configFile: false; + logLevel: 'silent'; + plugins: Array>; + 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(); + } + }); +}); From 51a9c07dbd9573aea9da851becd7732f92bb631f Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:42:42 -0400 Subject: [PATCH 3/4] #402 last round: serialise each environment's builds and end them at buildEnd spike-codex (P1, silent): a refused build owned the active generation's end. Under a builder reused after a failed parallel buildApp, the second client's buildEnd disposed the shared session directory before the original SSR build ended, which then fulfilled with animus.css referencing ./assets/badge.*.svg and no SVG emitted. As in vite-plugin (#395): a buildStart for an environment whose build is open (the same object, or one named alike) waits for that build's end, then opens its own generation; buildEnd, which runs once per build, ends only the generation its environment opened; the last to end disposes the session directory and releases the claim. No refusal is left, so no stray end. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/unplugin/src/core.ts | 75 ++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/unplugin/src/core.ts b/packages/unplugin/src/core.ts index d527cc0ea..5ef3169c4 100644 --- a/packages/unplugin/src/core.ts +++ b/packages/unplugin/src/core.ts @@ -501,57 +501,58 @@ export const unpluginFactory: UnpluginFactory< }); } - /** Each Rollup-family environment build that started and has not - * ended, by name. 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. Builds are tracked by environment, not by - * generation, so a second build of an active environment, or a second - * environment under an active name, is refused. webpack-like hosts run + /** 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(); + 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'; - /** Opens this hook's environment build; whether another environment's - * build was already open. */ - function openEnvironmentBuild(context: UnpluginBuildContext): boolean { + 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 = hasEnvironment(context) - ? context.environment - : singleEnvironment; - const { name } = environment; - const open = openBuilds.get(name); - if (open === environment) { - throw new Error( - `[animus] a previous build of environment ${name} is still running ` + - `on this plugin instance; wait for it to finish before building ` + - `it again` - ); + const environment = environmentOf(context); + for (;;) { + const open = openBuilds.get(environment.name); + if (open === undefined) break; + await open.ended; } - if (open !== undefined) { - throw new Error( - `[animus] two environments named ${name} build on this plugin ` + - `instance at once; give each environment its own name` - ); - } - openBuilds.set(name, environment); + 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 environment that opened an entry removes it, since a refused - * build ends too. */ + * the build that opened an entry ends it. */ function closeEnvironmentBuild(context: UnpluginBuildContext): boolean { if (!tracksEnvironments) return true; - const environment = hasEnvironment(context) - ? context.environment - : singleEnvironment; - if (openBuilds.get(environment.name) === environment) { - openBuilds.delete(environment.name); - } + 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; } @@ -630,7 +631,7 @@ export const unpluginFactory: UnpluginFactory< try { // Another environment's build already runs this build's pipeline // (a parallel Vite buildApp): share it rather than claim again. - if (openEnvironmentBuild(this)) await joinPipeline(); + if (await openEnvironmentBuild(this)) await joinPipeline(); else await startPipeline(); } finally { registerWatchTargets(this); From f799c47432af5e969b380206087a7dbdee951049 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Sun, 11 Oct 2026 17:42:42 -0400 Subject: [PATCH 4/4] #402 last round row: a reused builder keeps the earlier build its assets spike-codex's asset witness on the dev fixture: badge.svg through an asset() image scale read by a Badge background, SSR emitting assets, the builder reused while the slow original SSR build still runs. All four builds settle; the original SSR build fulfils and its output carries the badge SVG. Red at c449922a. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/vite-parallel-build.test.ts | 135 +++++++++++++++++- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/unplugin/tests/vite-parallel-build.test.ts b/packages/unplugin/tests/vite-parallel-build.test.ts index e86e44888..9e2ab5e5a 100644 --- a/packages/unplugin/tests/vite-parallel-build.test.ts +++ b/packages/unplugin/tests/vite-parallel-build.test.ts @@ -3,20 +3,55 @@ import { join } from 'path'; import { createBuilder } from 'vite-plus'; import { describe, expect, it } from 'vitest'; -import { createDevFixture } from '../../vite-plugin/tests/dev-server/fixture'; +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; rollupOptions: { input: string } }; + 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>; + plugins: Array< + ReturnType | typeof failClient | typeof slowSsr + >; environments: { client: EnvironmentBuild; ssr: EnvironmentBuild }; builder: { buildApp(app: ViteBuilder): Promise }; } @@ -82,4 +117,98 @@ describe('unplugin under Vite with environments built in parallel', () => { 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(); + } + }); });