Skip to content

Commit b066d1c

Browse files
authored
vite: Reduce unnecessary HMR invalidations (#1701)
1 parent fb82ead commit b066d1c

13 files changed

Lines changed: 393 additions & 44 deletions

File tree

.changeset/new-radios-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@vanilla-extract/compiler': minor
3+
---
4+
5+
Expose `compiler.findImporterTree` API

.changeset/weak-apricots-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@vanilla-extract/vite-plugin': patch
3+
---
4+
5+
Reduce unnecessary HMR invalidations

fixtures/low-level/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ document.body.innerHTML = `
88
</div>
99
</div>
1010
`;
11+
12+
// @ts-expect-error Vite env not defined
13+
if (import.meta.hot) {
14+
// @ts-expect-error Vite env not defined
15+
import.meta.hot.accept();
16+
}

packages/compiler/src/compiler.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ export interface Compiler {
246246
getCssForFile(virtualCssFilePath: string): { filePath: string; css: string };
247247
close(): Promise<void>;
248248
getAllCss(): string;
249+
findImporterTree(
250+
filePath: string,
251+
transformedVeModules: Set<string>,
252+
): Promise<Set<ModuleNode>>;
249253
}
250254

251255
interface ProcessedVanillaFile {
@@ -410,7 +414,10 @@ export const createCompiler = ({
410414
await lock(async () => {
411415
runner.cssAdapter = cssAdapter;
412416

413-
const fileExports = await runner.executeFile(filePath);
417+
const fileExports = (await runner.executeFile(filePath)) as Record<
418+
string,
419+
unknown
420+
>;
414421

415422
const moduleId = normalizePath(filePath);
416423
const moduleNode = server.moduleGraph.getModuleById(moduleId);
@@ -558,5 +565,56 @@ export const createCompiler = ({
558565

559566
return allCss;
560567
},
568+
/**
569+
* Returns an importer tree based off the compiler's module graph. We can't use the
570+
* consuming Vite dev server's module graph as it ends up modified by the `transform` hook to a
571+
* point where we can't reconstruct the original importer chain.
572+
*/
573+
async findImporterTree(filePath, transformedModules) {
574+
const { server } = await vitePromise;
575+
576+
// The compiler's module graph is always a subset of the consuming Vite dev server's module
577+
// graph, so this early exit will be hit for any modules that aren't involved in compiling VE
578+
// modules
579+
const moduleNode = server.moduleGraph.getModuleById(
580+
normalizePath(filePath),
581+
);
582+
if (!moduleNode) {
583+
return new Set();
584+
}
585+
586+
return _findImporterTree(moduleNode, transformedModules);
587+
},
561588
};
562589
};
590+
591+
function _findImporterTree(
592+
moduleNode: ModuleNode,
593+
transformedModules: Set<string>,
594+
visited = new Set<string>(),
595+
): Set<ModuleNode> {
596+
const result = new Set<ModuleNode>();
597+
if (!moduleNode.id || visited.has(moduleNode.id)) {
598+
return result;
599+
}
600+
601+
// Include the starting module in the tree
602+
result.add(moduleNode);
603+
visited.add(moduleNode.id);
604+
605+
// Stop if we hit a transformed module as this is a VE module boundary that we don't
606+
// need to invalidate past
607+
if (transformedModules.has(moduleNode.id)) {
608+
return result;
609+
}
610+
611+
for (const importer of moduleNode.importers) {
612+
const chain = _findImporterTree(importer, transformedModules, visited);
613+
614+
for (const mod of chain) {
615+
result.add(mod);
616+
}
617+
}
618+
619+
return result;
620+
}

packages/vite-plugin/src/index.ts

Lines changed: 94 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import type {
44
Plugin,
55
ResolvedConfig,
66
ConfigEnv,
7-
ViteDevServer,
87
PluginOption,
98
TransformResult,
109
UserConfig,
10+
ModuleNode,
11+
ViteDevServer,
1112
} from 'vite';
1213
import { type Compiler, createCompiler } from '@vanilla-extract/compiler';
1314
import {
@@ -70,6 +71,8 @@ export function vanillaExtractPlugin({
7071
let isBuild: boolean;
7172
const vitePromise = import('vite');
7273

74+
const transformedModules = new Set<string>();
75+
7376
const getIdentOption = () =>
7477
identifiers ?? (config.mode === 'production' ? 'short' : 'debug');
7578
const getAbsoluteId = (filePath: string) => {
@@ -92,22 +95,43 @@ export function vanillaExtractPlugin({
9295
return normalizePath(resolvedId);
9396
};
9497

95-
function invalidateModule(absoluteId: string) {
96-
if (!server) return;
97-
98+
/**
99+
* Custom invalidation function that takes a chain of importers to invalidate. If an importer is a
100+
* VE module, its virtual CSS is invalidated instead. Otherwise, the module is invalidated
101+
* normally.
102+
*/
103+
const invalidateImporterChain = ({
104+
importerChain,
105+
server,
106+
timestamp,
107+
}: {
108+
importerChain: Set<ModuleNode>;
109+
server: ViteDevServer;
110+
timestamp: number;
111+
}) => {
98112
const { moduleGraph } = server;
99-
const modules = moduleGraph.getModulesByFile(absoluteId);
100113

101-
if (modules) {
102-
for (const module of modules) {
103-
moduleGraph.invalidateModule(module);
114+
const seen = new Set<ModuleNode>();
104115

105-
// Vite uses this timestamp to add `?t=` query string automatically for HMR.
106-
module.lastHMRTimestamp =
107-
module.lastInvalidationTimestamp || Date.now();
116+
for (const mod of importerChain) {
117+
if (mod.id && cssFileFilter.test(mod.id)) {
118+
const virtualModules = moduleGraph.getModulesByFile(
119+
fileIdToVirtualId(mod.id),
120+
);
121+
122+
for (const virtualModule of virtualModules ?? []) {
123+
moduleGraph.invalidateModule(virtualModule, seen, timestamp, true);
124+
}
125+
} else if (mod.id) {
126+
// `mod` is from the compiler's internal Vite server, so look up the
127+
// corresponding module in the consuming server's graph by ID
128+
const serverMod = moduleGraph.getModuleById(mod.id);
129+
if (serverMod) {
130+
moduleGraph.invalidateModule(serverMod, seen, timestamp, true);
131+
}
108132
}
109133
}
110-
}
134+
};
111135

112136
return [
113137
{
@@ -138,6 +162,10 @@ export function vanillaExtractPlugin({
138162
name: PLUGIN_NAMESPACE,
139163
configureServer(_server) {
140164
server = _server;
165+
166+
server.watcher.on('unlink', (file) => {
167+
transformedModules.delete(normalizePath(file));
168+
});
141169
},
142170
config(_userConfig, _configEnv) {
143171
configEnv = _configEnv;
@@ -151,7 +179,7 @@ export function vanillaExtractPlugin({
151179
},
152180
};
153181
},
154-
async configResolved(_resolvedConfig) {
182+
configResolved(_resolvedConfig) {
155183
config = _resolvedConfig;
156184
isBuild = config.command === 'build' && !config.build.watch;
157185
packageName = getPackageInfo(config.root).name;
@@ -218,52 +246,75 @@ export function vanillaExtractPlugin({
218246
}
219247

220248
const identOption = getIdentOption();
249+
const normalizedId = normalizePath(validId);
221250

222251
if (unstable_mode === 'transform') {
252+
transformedModules.add(normalizedId);
253+
223254
return transform({
224255
source: code,
225-
filePath: normalizePath(validId),
256+
filePath: normalizedId,
226257
rootPath: config.root,
227258
packageName,
228259
identOption,
229260
});
230261
}
231262

232-
if (compiler) {
233-
const absoluteId = getAbsoluteId(validId);
263+
if (!compiler) {
264+
return null;
265+
}
234266

235-
const { source, watchFiles } = await compiler.processVanillaFile(
236-
absoluteId,
237-
{ outputCss: true },
238-
);
239-
const result: TransformResult = {
240-
code: source,
241-
map: { mappings: '' },
242-
};
267+
const absoluteId = getAbsoluteId(validId);
243268

244-
// We don't need to watch files or invalidate modules in build mode or during SSR
245-
if (isBuild || options?.ssr) {
246-
return result;
247-
}
269+
const { source, watchFiles } = await compiler.processVanillaFile(
270+
absoluteId,
271+
{ outputCss: true },
272+
);
248273

249-
for (const file of watchFiles) {
250-
if (
251-
!file.includes('node_modules') &&
252-
normalizePath(file) !== absoluteId
253-
) {
254-
this.addWatchFile(file);
255-
}
256-
257-
// We have to invalidate the virtual module & deps, not the real one we just transformed
258-
// The deps have to be invalidated in case one of them changing was the trigger causing
259-
// the current transformation
260-
if (cssFileFilter.test(file)) {
261-
invalidateModule(fileIdToVirtualId(file));
262-
}
263-
}
274+
transformedModules.add(normalizedId);
275+
276+
const result: TransformResult = {
277+
code: source,
278+
map: { mappings: '' },
279+
};
264280

281+
// We don't need to watch files or invalidate modules in build mode or during SSR
282+
if (isBuild || options?.ssr) {
265283
return result;
266284
}
285+
286+
for (const file of watchFiles) {
287+
if (
288+
!file.includes('node_modules') &&
289+
normalizePath(file) !== absoluteId
290+
) {
291+
this.addWatchFile(file);
292+
}
293+
}
294+
295+
return result;
296+
},
297+
// The compiler's module graph is always a subset of the consuming Vite dev server's module
298+
// graph, so this early exit will be hit for any modules that aren't related to VE modules.
299+
async handleHotUpdate({ file, server, timestamp }) {
300+
if (!compiler) {
301+
return;
302+
}
303+
304+
const importerChain = await compiler.findImporterTree(
305+
normalizePath(file),
306+
transformedModules,
307+
);
308+
309+
if (importerChain.size === 0) {
310+
return;
311+
}
312+
313+
invalidateImporterChain({
314+
importerChain,
315+
server,
316+
timestamp,
317+
});
267318
},
268319
resolveId(source) {
269320
const [validId, query] = source.split('?');

0 commit comments

Comments
 (0)