diff --git a/packages/extract/session/extraction-session.ts b/packages/extract/session/extraction-session.ts index 60190e13..d1ae5e22 100644 --- a/packages/extract/session/extraction-session.ts +++ b/packages/extract/session/extraction-session.ts @@ -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; @@ -648,6 +655,33 @@ export class ExtractionSession { // files. A failed reload keeps the last set — the stale config still serves. private systemDependencyKeys: Set = 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 { diff --git a/packages/extract/session/index.ts b/packages/extract/session/index.ts index 12b37eb7..61771705 100644 --- a/packages/extract/session/index.ts +++ b/packages/extract/session/index.ts @@ -11,6 +11,7 @@ export type { SessionOutcome, DeliveryIds, SessionPublication, + WatchPlan, WatchChanges, } from './extraction-session'; export { diff --git a/packages/extract/session/turbopack-orchestrator.ts b/packages/extract/session/turbopack-orchestrator.ts index 057f5ccb..a899cfe0 100644 --- a/packages/extract/session/turbopack-orchestrator.ts +++ b/packages/extract/session/turbopack-orchestrator.ts @@ -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>(); - 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(); files.add(path); diff --git a/packages/extract/tests/session/turbopack-watcher-registration.test.ts b/packages/extract/tests/session/turbopack-watcher-registration.test.ts index 11c92a1f..0a1f8c0e 100644 --- a/packages/extract/tests/session/turbopack-watcher-registration.test.ts +++ b/packages/extract/tests/session/turbopack-watcher-registration.test.ts @@ -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[] = []; diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index c2f39088..1b6a1cea 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -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); } }; diff --git a/packages/unplugin/src/core.ts b/packages/unplugin/src/core.ts index 81176f26..96ecb7ba 100644 --- a/packages/unplugin/src/core.ts +++ b/packages/unplugin/src/core.ts @@ -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); @@ -683,13 +680,14 @@ export const unpluginFactory: UnpluginFactory< state.analyzedFiles = [...analyzed.keys()].map((key) => resolve(root, key) ); - const watchPaths = new Set(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; }); } diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index cfeb52c5..cb404628 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -5,7 +5,6 @@ import { contentHash, DEFAULT_EXTENSIONS, findSheetAssetSpecifiers, - isPathWithinRoot, parseFilesJson, resolveAssetFile, resolveVerbosity, @@ -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(); + /** 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>(); // Per-plugin-instance engine state: two differently-configured plugins in // one process must not share an engine. @@ -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 diff --git a/packages/vite-plugin/tests/dev-server/replaced-server.test.ts b/packages/vite-plugin/tests/dev-server/replaced-server.test.ts new file mode 100644 index 00000000..8386a2a7 --- /dev/null +++ b/packages/vite-plugin/tests/dev-server/replaced-server.test.ts @@ -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 { + 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[]; +} + +/** The plugin's resolved stylesheet id; `\0` is Vite's virtual marker. */ +const STYLES_ID = '\0virtual:animus/styles.css'; + +const cleanups: Array<() => Promise | 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 => { + // 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; + 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 => { + // 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); diff --git a/packages/vite-plugin/tests/watch-registration.test.ts b/packages/vite-plugin/tests/watch-registration.test.ts index 31ec0187..cfd27f05 100644 --- a/packages/vite-plugin/tests/watch-registration.test.ts +++ b/packages/vite-plugin/tests/watch-registration.test.ts @@ -16,17 +16,22 @@ describe('registerSystemWatchPaths', () => { const ctx = new PluginContext({ system: './src/ds.ts' }); const add = vi.fn(); ctx.devServer = { watcher: { add } }; - // Registration reads only these three path lists of the Session. + // Registration reads the Session's watch plan, over these lists. ctx.session = Object.assign(Object.create(ExtractionSession.prototype), { + rootDir: null, + options: { system: './src/ds.ts' }, systemDependencyPaths: [], systemMissingPaths: [], + assetDependencyPaths: new Set(), + configInputPaths: [], + externalWatchRoots: [], externalPackageDirs: [], ...paths, }); return { ctx, add }; } - it('registers loader-reported system dependency paths, once per list', () => { + it('registers loader-reported system dependency paths, once each', () => { const { ctx, add } = contextWithWatcher({ systemDependencyPaths: ['/ws/tokens/src/theme.ts'], }); @@ -65,6 +70,25 @@ describe('registerSystemWatchPaths', () => { expect(add).toHaveBeenCalledWith(['/ws/ui-kit/src']); }); + // Contract: a reused plugin instance whose dev server is replaced + // registers every path with the new server's watcher; the old watcher's + // registrations do not carry over. + it("registers again with a replacement server's watcher", () => { + const { ctx, add } = contextWithWatcher({ + systemDependencyPaths: ['/ws/tokens/src/palette.ts'], + externalPackageDirs: ['/ws/ui-kit/src'], + }); + ctx.registerSystemWatchPaths(); + const addAgain = vi.fn(); + ctx.devServer = { watcher: { add: addAgain } }; + ctx.registerSystemWatchPaths(); + expect(add.mock.calls).toEqual([ + [['/ws/tokens/src/palette.ts']], + [['/ws/ui-kit/src']], + ]); + expect(addAgain.mock.calls).toEqual(add.mock.calls); + }); + it('no-ops without a dev server', () => { const { ctx } = contextWithWatcher({ externalPackageDirs: ['/ws/ui-kit/src'],