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
34 changes: 34 additions & 0 deletions packages/extract/session/extraction-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ export interface DeliveryIds {
systemPropsModuleId: string | typeof SESSION_ARTIFACT;
}

/** What a host watches for a Session (see `ExtractionSession.watchPlan`). */
export interface WatchPlan {
files: string[];
missing: string[];
directories: string[];
}

/** One published generation, as a host serving from memory reads it. */
export interface SessionPublication {
manifest: ProjectManifest;
Expand Down Expand Up @@ -648,6 +655,33 @@ export class ExtractionSession {
// files. A failed reload keeps the last set — the stale config still serves.
private systemDependencyKeys: Set<string> = new Set();

/** What a host watches for this Session, one list for every host: the
* files whose change it acts on (the system module and the modules it
* read, asset() sources, the configuration inputs), the paths the system
* load found absent, whose creation reloads it, and the external package
* roots, watched recursively. An absent path inside `covered` (roots the
* host already watches recursively) is left out: a host's recursive
* watch reports its creation, and watching an absent file inside a
* watched directory can stop that directory's events (inotify). */
watchPlan(covered: readonly string[] = []): WatchPlan {
const system =
this.rootDir === null ? [] : [resolve(this.rootDir, this.options.system)];
return {
files: [
...new Set([
...system,
...this.systemDependencyPaths,
...this.assetDependencyPaths,
...this.configInputPaths,
]),
],
missing: this.systemMissingPaths.filter(
(path) => !covered.some((root) => isPathWithinRoot(root, path))
),
directories: [...this.externalWatchRoots],
};
}

/** Whether `path` is the system module, one of its dependencies, or a
* path its load found absent: a change to it reloads the system. */
isSystemDependency(path: string): boolean {
Expand Down
1 change: 1 addition & 0 deletions packages/extract/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
SessionOutcome,
DeliveryIds,
SessionPublication,
WatchPlan,
WatchChanges,
} from './extraction-session';
export {
Expand Down
12 changes: 5 additions & 7 deletions packages/extract/session/turbopack-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,14 @@ export function startTurbopackWatcher(
);
};

/** Watches the system, asset and configuration dependencies no root
* watcher reports, by their directories, for those files only. */
/** Watches the Session's plan (its files, and the absent paths whose
* creation reloads the system) where no root watcher reports them, by
* their directories, for those paths only. */
const syncDependencyWatchers = (): void => {
if (closed) return;
const wanted = new Map<string, Set<string>>();
for (const path of [
...session.systemDependencyPaths,
...session.assetDependencyPaths,
...session.configInputPaths,
]) {
const plan = session.watchPlan();
for (const path of [...plan.files, ...plan.missing]) {
if (watchedByRoot(path)) continue;
const files = wanted.get(dirname(path)) ?? new Set<string>();
files.add(path);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,50 @@ describe('startTurbopackWatcher OS registration', () => {
}
});

// Contract: a path the system load found absent, outside the root's
// watch, is watched too; creating it reloads the system (spec §12.2 row 7).
test('an absent system path outside the root is watched, and creating it reloads', async () => {
const restoreGlobals = resetAnimusGlobals();
try {
const root = createProject();
const systemPath = join(root, 'src', 'system.ts');
writeFileSync(systemPath, 'export const system = {};\n');
const shared = makeTempRoot('animus-outside-missing-');
const absent = join(shared, 'tokens.tsx');
engine.loadSystemModule.mockReturnValue({
...SYSTEM_CONFIG,
dependencies: [systemPath],
missing: [absent],
});
const session = new ExtractionSession({ system: './src/system.ts' });
session.rootDir = root;
session.development = true;
const handle = startedHandle(
startTurbopackWatcher(session, root, {
debounceMs: 5,
watchFn: fakeWatch,
holdEvents: true,
})
);
await session.runFullPipeline();
await handle.deliverHeldEvents();
const watcher = () => watchers.find((w) => w.dir === shared && !w.closed);
expect(watcher()).toBeDefined();

const loads = engine.loadSystemModule.mock.calls.length;
writeFileSync(absent, 'export const t = 1;\n');
watcher()!.listener!('rename', 'tokens.tsx');
await vi.waitFor(() =>
expect(engine.loadSystemModule.mock.calls.length).toBe(loads + 1)
);
handle.close();
await handle.settle();
session.close();
} finally {
restoreGlobals();
}
});

test('a batch that arrives after close() never enters the session', async () => {
const root = createProject();
const entries: WatchChanges[] = [];
Expand Down
18 changes: 8 additions & 10 deletions packages/next-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,20 +369,18 @@ export class AnimusWebpackPlugin {
});

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: Compilation) => {
// The Session's watch plan, as webpack's dependency kinds: a path the
// system load found absent reloads it when created.
const registerSystemDependencies = () => {
for (const dep of this.session.systemDependencyPaths) {
if (existsSync(dep)) compilation.fileDependencies.add(dep);
else compilation.missingDependencies.add(dep);
const plan = this.session.watchPlan();
for (const path of plan.files) {
if (existsSync(path)) compilation.fileDependencies.add(path);
else compilation.missingDependencies.add(path);
}
// A path the system load found absent reloads it when created.
for (const path of this.session.systemMissingPaths) {
for (const path of plan.missing) {
compilation.missingDependencies.add(path);
}
for (const dep of this.session.assetDependencyPaths) {
if (existsSync(dep)) compilation.fileDependencies.add(dep);
else compilation.missingDependencies.add(dep);
}
for (const root of this.session.externalWatchRoots) {
for (const root of plan.directories) {
compilation.contextDependencies.add(root);
}
};
Expand Down
28 changes: 13 additions & 15 deletions packages/unplugin/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,14 +655,11 @@ export const unpluginFactory: UnpluginFactory<
try {
await session.runFullPipeline();
} catch (error) {
// A failed load still names what to watch for the retry: the files
// it read and the paths it found absent.
state.watchPaths = [
resolve(root, options.system),
...session.systemDependencyPaths,
...session.systemMissingPaths,
join(root, 'tsconfig.json'),
];
// A failed load still names what to watch for the retry: the
// Session's plan holds the files it read and the paths it found
// absent.
const plan = session.watchPlan();
state.watchPaths = [...plan.files, ...plan.missing];
throw error;
}
state.sessionDir = getSessionArtifactDir(session.key);
Expand All @@ -683,13 +680,14 @@ export const unpluginFactory: UnpluginFactory<
state.analyzedFiles = [...analyzed.keys()].map((key) =>
resolve(root, key)
);
const watchPaths = new Set<string>(state.analyzedFiles);
for (const dep of session.systemDependencyPaths) watchPaths.add(dep);
// Absent now; creating one rebuilds against a system that loads it.
for (const path of session.systemMissingPaths) watchPaths.add(path);
for (const dep of session.assetDependencyPaths) watchPaths.add(dep);
watchPaths.add(join(root, 'tsconfig.json'));
state.watchPaths = [...watchPaths];
// Rollup watches only its module graph: the analysed files join the
// Session's plan (an absent path's creation rebuilds against a system
// that loads it). Its directories need no watch: Rollup's graph holds
// the kit sources an import reaches.
const plan = session.watchPlan();
state.watchPaths = [
...new Set([...state.analyzedFiles, ...plan.files, ...plan.missing]),
];
state.transformFile = engineApi(session.key).transformFile;
});
}
Expand Down
62 changes: 26 additions & 36 deletions packages/vite-plugin/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
contentHash,
DEFAULT_EXTENSIONS,
findSheetAssetSpecifiers,
isPathWithinRoot,
parseFilesJson,
resolveAssetFile,
resolveVerbosity,
Expand Down Expand Up @@ -255,9 +254,9 @@ export class PluginContext {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
devServer: any;

/** The Session's path lists already registered with Vite's watcher; the
* Session replaces a list when it changes. */
private readonly watchedPathLists = new WeakSet<readonly string[]>();
/** The paths already registered, per watcher: a reused plugin instance
* whose dev server is replaced registers everything with the new one. */
private readonly watchedPaths = new WeakMap<object, Set<string>>();

// Per-plugin-instance engine state: two differently-configured plugins in
// one process must not share an engine.
Expand Down Expand Up @@ -671,46 +670,37 @@ export class PluginContext {
return this.session?.isSystemDependency(absFile) ?? false;
}

/** Watch every loader-reported dependency path, the external package
* roots, and the paths the load found absent that no watched directory
* covers (creating one reloads the system). Vite hard-ignores
* node_modules, so dependencies installed there produce no events. A list
* already registered is not added again. */
/** Registers the Session's watch plan with Vite's watcher, each path
* once per watcher. Vite also watches its packages' own directories beside the
* plan's canonical roots, and an absent path either covers (or the root
* does) is left to their watch: a watch on an absent file inside a
* watched directory stops that directory's events under inotify
* (chokidar's non-fsevents handler). Vite hard-ignores node_modules, so
* dependencies installed there produce no events. */
registerSystemWatchPaths(): void {
const watcher = this.devServer?.watcher;
const session = this.session;
if (!watcher || !session) return;
for (const paths of [
session.systemDependencyPaths,
session.systemMissingPaths,
session.externalPackageDirs,
]) {
if (paths.length === 0 || this.watchedPathLists.has(paths)) continue;
this.watchedPathLists.add(paths);
const added =
paths === session.systemMissingPaths
? this.uncoveredMissingPaths(paths, session.externalPackageDirs)
: paths;
const directories = [
...new Set([
...session.watchPlan().directories,
...session.externalPackageDirs,
]),
];
const covered = this.rootDir ? [this.rootDir, ...directories] : directories;
const plan = session.watchPlan(covered);
let registered = this.watchedPaths.get(watcher);
if (registered === undefined) {
registered = new Set();
this.watchedPaths.set(watcher, registered);
}
for (const paths of [plan.files, plan.missing, directories]) {
const added = paths.filter((path) => !registered.has(path));
for (const path of added) registered.add(path);
if (added.length > 0) watcher.add(added);
}
}

/** The absent paths no watched directory reports a creation for. Vite's
* root watch and a package root's watch already do, and a watch on an
* absent file inside a watched directory stops that directory's events
* under inotify (chokidar's non-fsevents handler). */
private uncoveredMissingPaths(
paths: readonly string[],
packageDirs: readonly string[]
): string[] {
const watchedDirs = this.rootDir
? [this.rootDir, ...packageDirs]
: packageDirs;
return paths.filter(
(path) => !watchedDirs.some((dir) => isPathWithinRoot(dir, path))
);
}

/** Invalidate the component CSS and system-props modules, then reload. The
* reload alone rescues nothing: Vite keeps serving its cached transform.
* `devServer.moduleGraph` is the mixed back-compat graph on purpose — it
Expand Down
108 changes: 108 additions & 0 deletions packages/vite-plugin/tests/dev-server/replaced-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { createServer as createNetServer } from 'net';
import { tmpdir } from 'os';
import { join, relative } from 'path';
import { createServer } from 'vite-plus';
import { afterEach, expect, test } from 'vitest';

import { animusExtract } from '../../src/index';
import { createDevFixture, INITIAL_BRAND_HEX } from './fixture';
import { until } from './scenario';

import type { AddressInfo } from 'net';
import type { ViteDevServer } from 'vite-plus';

/** A free loopback port: each server's HMR socket needs its own. */
function reserveHmrPort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createNetServer();
probe.on('error', reject);
probe.listen(0, '127.0.0.1', () => {
// SAFETY: a listening net.Server bound to a numeric port and IP host
// reports a TCP AddressInfo, never a Unix-socket string.
const { port } = probe.address() as AddressInfo;
probe.close(() => resolve(port));
});
});
}

/** What each of the two servers is started with. */
interface ReplacedServerConfig {
root: string;
configFile: false;
logLevel: 'silent';
optimizeDeps: { noDiscovery: true; include: never[] };
server: { middlewareMode: true; hmr: { port: number } };
plugins: ReturnType<typeof animusExtract>[];
}

/** The plugin's resolved stylesheet id; `\0` is Vite's virtual marker. */
const STYLES_ID = '\0virtual:animus/styles.css';

const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});

// Contract: a plugin instance reused with a replacement dev server watches
// the system's outside-root modules on the new server too, so their edits
// still reach the served CSS.
test("a replacement dev server watches the system's outside-root modules", async () => {
const fixture = createDevFixture();
cleanups.push(() => fixture.dispose());
const outside = mkdtempSync(join(tmpdir(), 'animus-outside-palette-'));
cleanups.push(() => rmSync(outside, { recursive: true, force: true }));
const palette = join(outside, 'palette.ts');
writeFileSync(palette, `export const brand = '${INITIAL_BRAND_HEX}';\n`);
const themePath = join(fixture.root, 'src/theme.ts');
const specifier = relative(join(fixture.root, 'src'), palette).replace(
/\.ts$/,
''
);
fixture.write(
'src/theme.ts',
`import { brand } from '${specifier}';\n` +
readFileSync(themePath, 'utf8').replace(`'${INITIAL_BRAND_HEX}'`, 'brand')
);

// One plugin object, as a host that restarts its server keeps it.
const plugin = animusExtract({ system: './src/ds.ts' });
const start = async (): Promise<ViteDevServer> => {
// SAFETY: vite-plus runs Vite's server and consumes the same plugin hooks;
// only the nominal declarations differ.
const startServer = createServer as (
config: ReplacedServerConfig
) => Promise<ViteDevServer>;
const server = await startServer({
root: fixture.root,
configFile: false,
logLevel: 'silent',
optimizeDeps: { noDiscovery: true, include: [] },
server: { middlewareMode: true, hmr: { port: await reserveHmrPort() } },
plugins: [plugin],
});
for (const url of ['/src/main.ts', '/src/Button.ts']) {
await server.environments.client.transformRequest(url);
}
return server;
};
const staticCss = async (server: ViteDevServer): Promise<string> => {
// As the adapter reads it: the graph node first, as import analysis
// registers it, then the module.
const environment = server.environments.client;
await environment.moduleGraph.ensureEntryFromUrl(STYLES_ID, false);
const result = await environment.transformRequest(STYLES_ID);
return result?.code ?? '';
};

const first = await start();
await first.close();
const second = await start();
cleanups.push(() => second.close());
expect(await staticCss(second)).toContain(INITIAL_BRAND_HEX);

writeFileSync(palette, `export const brand = '#ff0000';\n`);
await until(async () => (await staticCss(second)).includes('#ff0000'), {
what: 'the outside-root palette edit in the served CSS',
});
}, 30_000);
Loading
Loading