Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vite-plugin/src/build-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions packages/vite-plugin/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
end(): void;
}

export class PluginContext {
readonly options: AnimusExtractOptions;
readonly verbose: boolean;
Expand Down Expand Up @@ -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<string, OpenBuild>();

/** 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<void> {
for (;;) {
const open = this.openBuilds.get(environment.name);
if (open === undefined) break;
await open.ended;
}
let end = () => {};
const ended = new Promise<void>((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. */
Expand Down
10 changes: 7 additions & 3 deletions packages/vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
196 changes: 196 additions & 0 deletions packages/vite-plugin/tests/dev-server/build-app.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof animusExtract>
>;
environments: { client: EnvironmentBuild; ssr: EnvironmentBuild };
builder: { buildApp(app: ViteBuilder): Promise<void> };
}

type StartParallelBuild = (
config: ParallelBuildConfig
) => Promise<{ buildApp(): Promise<void> }>;

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<Promise<object | void>> = [];
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<void> = 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 },
});
}
Loading