From 5dfdca0199da2544c3877575e09dd1c3b75e9f9a Mon Sep 17 00:00:00 2001 From: Adam Skoufis Date: Wed, 10 Sep 2025 17:07:20 +1000 Subject: [PATCH 1/3] `vite`: Prevent unnecessary HMR invalidations --- .changeset/new-radios-swim.md | 5 + .changeset/weak-apricots-know.md | 5 + packages/compiler/src/compiler.ts | 72 +++++++++- packages/vite-plugin/src/index.ts | 128 ++++++++++++------ tests/compiler/compiler.test.ts | 119 +++++++++++++++- .../importer-tree/evenMoreStyles.css.ts | 4 + .../fixtures/importer-tree/moreStyles.css.ts | 4 + .../fixtures/importer-tree/moreStyles2.css.ts | 3 + .../fixtures/importer-tree/otherTokens.ts | 5 + .../fixtures/importer-tree/reExporter.ts | 1 + .../fixtures/importer-tree/styles.css.ts | 4 + .../compiler/fixtures/importer-tree/tokens.ts | 7 + 12 files changed, 311 insertions(+), 46 deletions(-) create mode 100644 .changeset/new-radios-swim.md create mode 100644 .changeset/weak-apricots-know.md create mode 100644 tests/compiler/fixtures/importer-tree/evenMoreStyles.css.ts create mode 100644 tests/compiler/fixtures/importer-tree/moreStyles.css.ts create mode 100644 tests/compiler/fixtures/importer-tree/moreStyles2.css.ts create mode 100644 tests/compiler/fixtures/importer-tree/otherTokens.ts create mode 100644 tests/compiler/fixtures/importer-tree/reExporter.ts create mode 100644 tests/compiler/fixtures/importer-tree/styles.css.ts create mode 100644 tests/compiler/fixtures/importer-tree/tokens.ts diff --git a/.changeset/new-radios-swim.md b/.changeset/new-radios-swim.md new file mode 100644 index 000000000..5bfb4851e --- /dev/null +++ b/.changeset/new-radios-swim.md @@ -0,0 +1,5 @@ +--- +'@vanilla-extract/compiler': minor +--- + +Expose `compiler.findImporterTree` API diff --git a/.changeset/weak-apricots-know.md b/.changeset/weak-apricots-know.md new file mode 100644 index 000000000..cb12c4f1d --- /dev/null +++ b/.changeset/weak-apricots-know.md @@ -0,0 +1,5 @@ +--- +'@vanilla-extract/vite-plugin': patch +--- + +Reduce unnecessary HMR invalidations diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 9f524922b..caaaa05b2 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -2,7 +2,11 @@ import assert from 'assert'; import { join, isAbsolute } from 'path'; import type { Adapter } from '@vanilla-extract/css'; import { transformCss } from '@vanilla-extract/css/transformCss'; -import type { ModuleNode, UserConfig as ViteUserConfig } from 'vite'; +import type { + ModuleNode, + ViteDevServer, + UserConfig as ViteUserConfig, +} from 'vite'; import { cssFileFilter, @@ -241,6 +245,10 @@ export interface Compiler { getCssForFile(virtualCssFilePath: string): { filePath: string; css: string }; close(): Promise; getAllCss(): string; + findImporterTree( + filePath: string, + transformedVeModules: Set, + ): Promise>; } interface ProcessedVanillaFile { @@ -394,7 +402,10 @@ export const createCompiler = ({ await lock(async () => { runner.cssAdapter = cssAdapter; - const fileExports = await runner.executeFile(filePath); + const fileExports = (await runner.executeFile(filePath)) as Record< + string, + unknown + >; const moduleId = normalizePath(filePath); const moduleNode = server.moduleGraph.getModuleById(moduleId); @@ -511,5 +522,62 @@ export const createCompiler = ({ return allCss; }, + /** + * Returns an importer tree based off the compiler's module graph. We can't use the + * consuming Vite dev server's module graph as it ends up modified by the `transform` hook to a + * point where we can't reconstruct the original importer chain. + */ + async findImporterTree(filePath, transformedModules) { + const { server } = await vitePromise; + + // The compiler's module graph is always a subset of the consuming Vite dev server's module + // graph, so this early exit will be hit for any modules that aren't involved in compiling VE + // modules + const moduleNode = server.moduleGraph.getModuleById( + normalizePath(filePath), + ); + if (!moduleNode) { + return new Set(); + } + + return _findImporterTree(moduleNode, transformedModules, server); + }, }; }; + +function _findImporterTree( + moduleNode: ModuleNode, + transformedModules: Set, + server: ViteDevServer, + visited = new Set(), +): Set { + const result = new Set(); + if (!moduleNode.id || visited.has(moduleNode.id)) { + return result; + } + + // Include the starting module in the tree + result.add(moduleNode); + visited.add(moduleNode.id); + + // Stop if we hit a transformed module as this is a VE module boundary that we don't + // need to invalidate past + if (transformedModules.has(moduleNode.id)) { + return result; + } + + for (const importer of moduleNode.importers) { + const chain = _findImporterTree( + importer, + transformedModules, + server, + visited, + ); + + for (const mod of chain) { + result.add(mod); + } + } + + return result; +} diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index f1ad964ea..d20046abd 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -4,10 +4,11 @@ import type { Plugin, ResolvedConfig, ConfigEnv, - ViteDevServer, PluginOption, TransformResult, UserConfig, + ModuleNode, + ViteDevServer, } from 'vite'; import { type Compiler, createCompiler } from '@vanilla-extract/compiler'; import { @@ -70,6 +71,8 @@ export function vanillaExtractPlugin({ let isBuild: boolean; const vitePromise = import('vite'); + const transformedModules = new Set(); + const getIdentOption = () => identifiers ?? (config.mode === 'production' ? 'short' : 'debug'); const getAbsoluteId = (filePath: string) => { @@ -92,22 +95,38 @@ export function vanillaExtractPlugin({ return normalizePath(resolvedId); }; - function invalidateModule(absoluteId: string) { - if (!server) return; - + /** + * Custom invalidation function that takes a chain of importers to invalidate. If an importer is a + * VE module, its virtual CSS is invalidated instead. Otherwise, the module is invalidated + * normally. + */ + const invalidateImporterChain = ({ + importerChain, + server, + timestamp, + }: { + importerChain: Set; + server: ViteDevServer; + timestamp: number; + }) => { const { moduleGraph } = server; - const modules = moduleGraph.getModulesByFile(absoluteId); - if (modules) { - for (const module of modules) { - moduleGraph.invalidateModule(module); + const seen = new Set(); + + for (const mod of importerChain) { + if (mod.id && cssFileFilter.test(mod.id)) { + const virtualModules = moduleGraph.getModulesByFile( + fileIdToVirtualId(mod.id), + ); - // Vite uses this timestamp to add `?t=` query string automatically for HMR. - module.lastHMRTimestamp = - module.lastInvalidationTimestamp || Date.now(); + for (const virtualModule of virtualModules ?? []) { + moduleGraph.invalidateModule(virtualModule, seen, timestamp, true); + } + } else { + moduleGraph.invalidateModule(mod, seen, timestamp, true); } } - } + }; return [ { @@ -138,6 +157,10 @@ export function vanillaExtractPlugin({ name: PLUGIN_NAMESPACE, configureServer(_server) { server = _server; + + server.watcher.on('unlink', (file) => { + transformedModules.delete(file); + }); }, config(_userConfig, _configEnv) { configEnv = _configEnv; @@ -151,7 +174,7 @@ export function vanillaExtractPlugin({ }, }; }, - async configResolved(_resolvedConfig) { + configResolved(_resolvedConfig) { config = _resolvedConfig; isBuild = config.command === 'build' && !config.build.watch; packageName = getPackageInfo(config.root).name; @@ -218,52 +241,71 @@ export function vanillaExtractPlugin({ } const identOption = getIdentOption(); + const normalizedId = normalizePath(validId); if (unstable_mode === 'transform') { + transformedModules.add(normalizedId); + return transform({ source: code, - filePath: normalizePath(validId), + filePath: normalizedId, rootPath: config.root, packageName, identOption, }); } - if (compiler) { - const absoluteId = getAbsoluteId(validId); + if (!compiler) { + return null; + } + + const absoluteId = getAbsoluteId(validId); - const { source, watchFiles } = await compiler.processVanillaFile( - absoluteId, - { outputCss: true }, - ); - const result: TransformResult = { - code: source, - map: { mappings: '' }, - }; + const { source, watchFiles } = await compiler.processVanillaFile( + absoluteId, + { outputCss: true }, + ); - // We don't need to watch files or invalidate modules in build mode or during SSR - if (isBuild || options.ssr) { - return result; - } + transformedModules.add(normalizedId); - for (const file of watchFiles) { - if ( - !file.includes('node_modules') && - normalizePath(file) !== absoluteId - ) { - this.addWatchFile(file); - } - - // We have to invalidate the virtual module & deps, not the real one we just transformed - // The deps have to be invalidated in case one of them changing was the trigger causing - // the current transformation - if (cssFileFilter.test(file)) { - invalidateModule(fileIdToVirtualId(file)); - } - } + const result: TransformResult = { + code: source, + map: { mappings: '' }, + }; + // We don't need to watch files in build mode or during SSR + if (isBuild || options.ssr) { return result; } + + for (const file of watchFiles) { + if ( + !file.includes('node_modules') && + normalizePath(file) !== absoluteId + ) { + this.addWatchFile(file); + } + } + + return result; + }, + // The compiler's module graph is always a subset of the consuming Vite dev server's module + // graph, so this early exit will be hit for any modules that aren't related to VE modules. + async handleHotUpdate({ file, server, timestamp }) { + const importerChain = await compiler?.findImporterTree( + normalizePath(file), + transformedModules, + ); + + if (!importerChain || importerChain.size === 0) { + return; + } + + invalidateImporterChain({ + importerChain, + server, + timestamp, + }); }, resolveId(source) { const [validId, query] = source.split('?'); diff --git a/tests/compiler/compiler.test.ts b/tests/compiler/compiler.test.ts index 350ca31dd..bd09fa8b2 100644 --- a/tests/compiler/compiler.test.ts +++ b/tests/compiler/compiler.test.ts @@ -25,7 +25,8 @@ describe('compiler', () => { | 'vitePlugins' | 'tsconfigPaths' | 'basePath' - | 'getAllCss', + | 'getAllCss' + | 'importerTree', ReturnType >; @@ -83,6 +84,9 @@ describe('compiler', () => { getAllCss: createCompiler({ root: __dirname, }), + importerTree: createCompiler({ + root: __dirname, + }), }; }); @@ -583,4 +587,117 @@ describe('compiler', () => { `); }); + + test('should generate correct importer trees', async () => { + const compiler = compilers.importerTree; + + const stylesPath = path.join( + __dirname, + 'fixtures/importer-tree/styles.css.ts', + ); + const moreStylesPath = path.join( + __dirname, + 'fixtures/importer-tree/moreStyles.css.ts', + ); + const evenMoreStylesPath = path.join( + __dirname, + 'fixtures/importer-tree/evenMoreStyles.css.ts', + ); + + await Promise.all([ + compiler.processVanillaFile(stylesPath), + compiler.processVanillaFile(moreStylesPath), + compiler.processVanillaFile(evenMoreStylesPath), + ]); + + const transformedVeModules = new Set([ + path.resolve(__dirname, stylesPath), + path.resolve(__dirname, evenMoreStylesPath), + ]); + + { + const importerTree = await compiler.findImporterTree( + path.resolve(stylesPath), + transformedVeModules, + ); + + const res = Array.from(importerTree).map((importer) => importer.id); + + expect(res).toMatchInlineSnapshot(` + [ + {{__dirname}}/fixtures/importer-tree/styles.css.ts, + ] + `); + } + + { + const importerTree = await compiler.findImporterTree( + path.resolve(moreStylesPath), + transformedVeModules, + ); + + const res = Array.from(importerTree).map((importer) => importer.id); + + expect(res).toMatchInlineSnapshot(` + [ + {{__dirname}}/fixtures/importer-tree/moreStyles.css.ts, + ] + `); + } + + { + const importerTree = await compiler.findImporterTree( + path.resolve(path.join(__dirname, 'fixtures/importer-tree/tokens.ts')), + transformedVeModules, + ); + + const res = Array.from(importerTree).map((importer) => importer.id); + + expect(res).toMatchInlineSnapshot(` + [ + {{__dirname}}/fixtures/importer-tree/tokens.ts, + {{__dirname}}/fixtures/importer-tree/styles.css.ts, + ] + `); + } + + { + const importerTree = await compiler.findImporterTree( + path.resolve( + path.join(__dirname, 'fixtures/importer-tree/otherTokens.ts'), + ), + transformedVeModules, + ); + + const res = Array.from(importerTree).map((importer) => importer.id); + + expect(res).toMatchInlineSnapshot(` + [ + {{__dirname}}/fixtures/importer-tree/otherTokens.ts, + {{__dirname}}/fixtures/importer-tree/tokens.ts, + {{__dirname}}/fixtures/importer-tree/styles.css.ts, + {{__dirname}}/fixtures/importer-tree/moreStyles.css.ts, + ] + `); + } + + { + const importerTree = await compiler.findImporterTree( + path.resolve( + path.join(__dirname, 'fixtures/importer-tree/moreStyles2.css.ts'), + ), + transformedVeModules, + ); + + const res = Array.from(importerTree).map((importer) => importer.id); + + expect(res).toMatchInlineSnapshot(` + [ + {{__dirname}}/fixtures/importer-tree/moreStyles2.css.ts, + {{__dirname}}/fixtures/importer-tree/reExporter.ts, + {{__dirname}}/fixtures/importer-tree/evenMoreStyles.css.ts, + ] + `); + } + }); }); diff --git a/tests/compiler/fixtures/importer-tree/evenMoreStyles.css.ts b/tests/compiler/fixtures/importer-tree/evenMoreStyles.css.ts new file mode 100644 index 000000000..ae157d264 --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/evenMoreStyles.css.ts @@ -0,0 +1,4 @@ +import { style } from '@vanilla-extract/css'; +import { reExportedStyle } from './reExporter'; + +export const container = style([reExportedStyle, {}]); diff --git a/tests/compiler/fixtures/importer-tree/moreStyles.css.ts b/tests/compiler/fixtures/importer-tree/moreStyles.css.ts new file mode 100644 index 000000000..b9c76114f --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/moreStyles.css.ts @@ -0,0 +1,4 @@ +import { style } from '@vanilla-extract/css'; +import { otherTokens } from './otherTokens'; + +export const bar = style({ borderColor: otherTokens.color.secondary }); diff --git a/tests/compiler/fixtures/importer-tree/moreStyles2.css.ts b/tests/compiler/fixtures/importer-tree/moreStyles2.css.ts new file mode 100644 index 000000000..9de237ad2 --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/moreStyles2.css.ts @@ -0,0 +1,3 @@ +import { style } from '@vanilla-extract/css'; + +export const baz = style({}); diff --git a/tests/compiler/fixtures/importer-tree/otherTokens.ts b/tests/compiler/fixtures/importer-tree/otherTokens.ts new file mode 100644 index 000000000..8fb68ca47 --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/otherTokens.ts @@ -0,0 +1,5 @@ +export const otherTokens = { + color: { + secondary: 'blue', + }, +}; diff --git a/tests/compiler/fixtures/importer-tree/reExporter.ts b/tests/compiler/fixtures/importer-tree/reExporter.ts new file mode 100644 index 000000000..3ad3a6d5d --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/reExporter.ts @@ -0,0 +1 @@ +export { baz as reExportedStyle } from './moreStyles2.css'; diff --git a/tests/compiler/fixtures/importer-tree/styles.css.ts b/tests/compiler/fixtures/importer-tree/styles.css.ts new file mode 100644 index 000000000..2b729b988 --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/styles.css.ts @@ -0,0 +1,4 @@ +import { style } from '@vanilla-extract/css'; +import { tokens } from './tokens'; + +export const foo = style({ color: tokens.color.primary }); diff --git a/tests/compiler/fixtures/importer-tree/tokens.ts b/tests/compiler/fixtures/importer-tree/tokens.ts new file mode 100644 index 000000000..c24b1e6ce --- /dev/null +++ b/tests/compiler/fixtures/importer-tree/tokens.ts @@ -0,0 +1,7 @@ +import { otherTokens } from './otherTokens'; +export const tokens = { + color: { + primary: 'red', + ...otherTokens.color, + }, +}; From 96b58d420c6baba3b25d76f56402a970095ad339 Mon Sep 17 00:00:00 2001 From: "PAU COLOME (Sparta)" Date: Thu, 27 Nov 2025 11:25:25 +0100 Subject: [PATCH 2/3] fix: deeply nested changes not applying --- packages/compiler/src/compiler.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 45eb96f71..28000ab8b 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -98,10 +98,13 @@ const createViteServer = async ({ identifiers, viteConfig, enableFileWatcher = true, + onFileChange, }: Required< Pick > & - Pick) => { + Pick & { + onFileChange?: (filePath: string) => void; + }) => { const pkg = getPackageInfo(root); const vite = await import('vite'); @@ -204,6 +207,7 @@ const createViteServer = async ({ if (enableFileWatcher) { server.watcher.on('change', (filePath) => { runner.moduleCache.invalidateDepTree([filePath]); + onFileChange?.(filePath); }); } @@ -287,6 +291,14 @@ export const createCompiler = ({ 'viteConfig cannot be used with viteResolve or vitePlugins', ); + const processVanillaFileCache = new Map< + string, + { + lastInvalidationTimestamp: number; + result: ProcessedVanillaFile; + } + >(); + const vitePromise = createViteServer({ root, identifiers, @@ -295,16 +307,16 @@ export const createCompiler = ({ plugins: vitePlugins, }, enableFileWatcher, + onFileChange: (changedFilePath) => { + const normalizedPath = normalizePath(changedFilePath); + for (const [cacheKey, cached] of processVanillaFileCache.entries()) { + if (cached.result.watchFiles.has(normalizedPath)) { + processVanillaFileCache.delete(cacheKey); + } + } + }, }); - const processVanillaFileCache = new Map< - string, - { - lastInvalidationTimestamp: number; - result: ProcessedVanillaFile; - } - >(); - const cssCache = new NormalizedMap<{ css: string }>(root); const classRegistrationsByModuleId = new NormalizedMap<{ localClassNames: Set; From 2a37ad113fdc6bb1579e0817ac11226afe1c8641 Mon Sep 17 00:00:00 2001 From: "PAU COLOME (Sparta)" Date: Thu, 27 Nov 2025 11:26:02 +0100 Subject: [PATCH 3/3] fix: prevent double cache invalidation --- packages/vite-plugin/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index d20046abd..af829bbba 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -306,6 +306,10 @@ export function vanillaExtractPlugin({ server, timestamp, }); + + // return empty array to tell Vite we've handled HMR ourselves, + // preventing Vite's default propagation which causes double invalidation. + return []; }, resolveId(source) { const [validId, query] = source.split('?');