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
20 changes: 20 additions & 0 deletions packages/unplugin/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@ interface WebpackLikeCompiler {
hooks: {
done: { tap: (name: string, fn: () => void) => void };
failed?: { tap: (name: string, fn: () => void) => void };
/** `close()` of a watch, which webpack fires in place of `done` when it
* closes mid-compile. */
watchClose?: { tap: (name: string, fn: () => void) => void };
normalModuleFactory: {
tap: (
name: string,
Expand Down Expand Up @@ -403,6 +406,18 @@ export const unpluginFactory: UnpluginFactory<

async function startPipeline(): Promise<void> {
signalPipelineStarted();
// A webpack compile that ended without done or failed (it restarts one
// an edit invalidated, and skips both) left its Session joined and its
// claim held: retire it before this run claims the build again. A
// webpack compiler runs one compile at a time; the Rollup family can
// start environments in parallel, so they keep theirs.
if (
activeSession !== null &&
(meta.framework === 'webpack' || meta.framework === 'rspack')
) {
disposeSessionDir(state);
releaseClaim();
}
try {
await runClaimedPipeline();
} catch (error) {
Expand Down Expand Up @@ -933,5 +948,10 @@ export const unpluginFactory: UnpluginFactory<
disposeSessionDir(state);
releaseClaim();
});
// A watch closed mid-compile fires neither: the build retires here.
compiler.hooks.watchClose?.tap(PLUGIN_NAME, () => {
disposeSessionDir(state);
releaseClaim();
});
}
};
101 changes: 101 additions & 0 deletions packages/unplugin/tests/failed-load-watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';

import { setEngineApiOverride } from '../../extract/session/singleton';
import {
buildManifest,
createProject,
disposeTempRoots,
PLAN_A,
resetAnimusGlobals,
SYSTEM_CONFIG,
} from '../../extract/tests/session/session-fixtures';
import { parserStoppingAt } from '../../extract/tests/source-ingestion-fixtures';
import { unpluginFactory } from '../src/core';

let restoreGlobals: (() => void) | null = null;
Expand Down Expand Up @@ -60,3 +65,99 @@ describe("a failed load's observations are watched", () => {
expect(watched).toEqual(expect.arrayContaining([palette, absent]));
});
});

/** A webpack compiler's hooks, recorded so a row can fire them. */
function fakeWebpackCompiler() {
const done: Array<() => void> = [];
const failed: Array<() => void> = [];
const watchClose: Array<() => void> = [];
const taps = { done, failed, watchClose };
const hook = (name: keyof typeof taps) => ({
tap: (_plugin: string, fn: () => void) => {
taps[name].push(fn);
},
});
const compiler = {
options: { mode: 'development', resolve: {} },
watchMode: true,
webpack: {
version: '5.105.4',
DefinePlugin: class {
apply() {}
},
},
hooks: {
done: hook('done'),
failed: hook('failed'),
watchClose: hook('watchClose'),
normalModuleFactory: { tap: () => {} },
},
};
return {
compiler,
fire: (name: keyof typeof taps) => taps[name].forEach((fn) => fn()),
};
}

/** A webpack plugin instance over a project whose system loads, wired to a
* fake compiler; `make` runs its buildStart, which webpack maps to make. */
function webpackHost(root: string) {
const { compiler, fire } = fakeWebpackCompiler();
const plugin = unpluginFactory(
{ system: './src/system.ts', root },
// SAFETY: the fake models every compiler member the plugin's webpack
// wiring reads (options, watchMode, webpack.DefinePlugin, hooks).
{
framework: 'webpack',
versions: {},
webpack: { compiler: compiler as never },
}
);
if (Array.isArray(plugin) || !plugin.webpack || !plugin.buildStart) {
throw new Error('expected one plugin with webpack and buildStart hooks');
}
// SAFETY: as above.
plugin.webpack(compiler as never);
const buildStart = plugin.buildStart;
// SAFETY: the hook reads nothing from its context under webpack.
const make = () => buildStart.call({} as never);
return { make, fire };
}

describe('a webpack compile that ends without done or failed', () => {
function loadableSystem(): string {
restoreGlobals = resetAnimusGlobals();
const root = createProject('animus-unplugin-webpack-');
setEngineApiOverride(() => ({
extractFacts: parserStoppingAt('(('),
loadSystemModule: () => ({ ...SYSTEM_CONFIG, dependencies: [] }),
analyzeProject: () => buildManifest(PLAN_A),
transformFile: (source: string) => ({
code: source,
hasComponents: false,
}),
clearAnalysisCache: () => {},
}));
return root;
}

test('an edit that restarts the compile never strands its Session', async () => {
const { make } = webpackHost(loadableSystem());

await make();
// webpack restarted the invalidated compile: no done, no failed.
await expect(make()).resolves.toBeUndefined();
});

test('a watch closed mid-compile retires its build', async () => {
const root = loadableSystem();
const sessions = join(root, '.animus', 'sessions');
const { make, fire } = webpackHost(root);
await make();
expect(readdirSync(sessions)).toHaveLength(1);

// webpack closed the watch mid-compile: watchClose, no done.
fire('watchClose');
expect(existsSync(sessions) ? readdirSync(sessions) : []).toEqual([]);
});
});
Loading