Skip to content

Commit c18772e

Browse files
committed
fix(hmr): route CSS hot-reload + stop dev false-reloads
1 parent 05ba118 commit c18772e

16 files changed

Lines changed: 505 additions & 227 deletions

File tree

.changeset/dev-hmr.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@qwik.dev/core': patch
3+
'@qwik.dev/router': patch
4+
---
5+
6+
fix: hot-reload route/layout CSS in dev, re-render source edits in place, and stop spurious full reloads

.changeset/hmr-devpath-matching.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@qwik.dev/qwik-vite': patch
3+
'@qwik.dev/core': patch
4+
---
5+
6+
fix: harden dev HMR path matching so sibling-prefix and out-of-root component files reload correctly
Lines changed: 42 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,50 @@
1-
import { describe, it, expect, vi } from 'vitest';
2-
import type { ViteDevServer } from 'vite';
3-
import { sendRouterCssHotUpdate } from './dev-middleware';
1+
import { describe, it, expect } from 'vitest';
2+
import { buildRouterCssTags } from './dev-middleware';
43

5-
const file = '/app/src/routes/docs/docs.css';
6-
7-
/** Minimal ViteDevServer stand-in with controllable client/SSR graphs and a spy on the HMR channel. */
8-
function makeServer(opts: { client?: { url: string }[]; ssr?: { url: string }[] }) {
9-
const send = vi.fn();
10-
const graph = (mods?: { url: string }[]) => ({
11-
getModulesByFile: () => (mods ? new Set(mods) : undefined),
12-
});
13-
const server = {
14-
environments: {
15-
client: { moduleGraph: graph(opts.client), hot: { send } },
16-
ssr: { moduleGraph: graph(opts.ssr) },
17-
},
18-
} as unknown as ViteDevServer;
19-
return { server, send };
20-
}
21-
22-
describe('sendRouterCssHotUpdate', () => {
23-
it('ignores non-CSS files', () => {
24-
const { server, send } = makeServer({ ssr: [{ url: '/x.tsx' }] });
25-
expect(sendRouterCssHotUpdate(server, '/app/src/routes/index.tsx', 1)).toBe(false);
26-
expect(send).not.toHaveBeenCalled();
4+
describe('buildRouterCssTags', () => {
5+
it('inlines a <style> that seeds Vite dedup, plus a module import for native HMR', () => {
6+
const tags = buildRouterCssTags([
7+
{
8+
id: '/app/src/routes/docs/docs.css',
9+
url: '/src/routes/docs/docs.css',
10+
css: '.a{color:red}',
11+
},
12+
]);
13+
expect(tags).toEqual([
14+
{
15+
tag: 'style',
16+
attrs: { 'data-vite-dev-id': '/app/src/routes/docs/docs.css' },
17+
children: '.a{color:red}',
18+
injectTo: 'head',
19+
},
20+
{
21+
tag: 'script',
22+
attrs: { type: 'module' },
23+
children: 'import "/src/routes/docs/docs.css"',
24+
injectTo: 'head',
25+
},
26+
]);
2727
});
2828

29-
it('emits a deduped css-update for route CSS that only lives in the SSR graph', () => {
30-
const { server, send } = makeServer({
31-
ssr: [{ url: '/src/routes/docs/docs.css' }, { url: '/src/routes/docs/docs.css?inline' }],
32-
});
33-
expect(sendRouterCssHotUpdate(server, file, 123)).toBe(true);
34-
expect(send).toHaveBeenCalledWith({
35-
type: 'update',
36-
updates: [
37-
{
38-
type: 'css-update',
39-
path: '/src/routes/docs/docs.css',
40-
acceptedPath: '/src/routes/docs/docs.css',
41-
timestamp: 123,
42-
},
43-
],
29+
it('falls back to a <link> when the CSS could not be inlined', () => {
30+
const tags = buildRouterCssTags([
31+
{ id: '/app/src/routes/docs/docs.css', url: '/src/routes/docs/docs.css', css: '' },
32+
]);
33+
expect(tags[0]).toEqual({
34+
tag: 'link',
35+
attrs: { rel: 'stylesheet', href: '/src/routes/docs/docs.css' },
36+
injectTo: 'head',
4437
});
38+
// Still imports the module so Vite tracks + HMRs it.
39+
expect(tags[1]).toMatchObject({ tag: 'script', attrs: { type: 'module' } });
4540
});
4641

47-
it('defers to Vite when the CSS is already in the client graph', () => {
48-
const { server, send } = makeServer({
49-
client: [{ url: '/src/routes/docs/docs.css' }],
50-
ssr: [{ url: '/src/routes/docs/docs.css' }],
51-
});
52-
expect(sendRouterCssHotUpdate(server, file, 1)).toBe(false);
53-
expect(send).not.toHaveBeenCalled();
42+
it('emits one style + one script per CSS module', () => {
43+
const tags = buildRouterCssTags([
44+
{ id: '/a.css', url: '/a.css', css: '.a{}' },
45+
{ id: '/b.css', url: '/b.css', css: '.b{}' },
46+
]);
47+
expect(tags.filter((t) => t.tag === 'style')).toHaveLength(2);
48+
expect(tags.filter((t) => t.tag === 'script')).toHaveLength(2);
5449
});
5550
});

packages/qwik-router/src/buildtime/vite/dev-middleware.ts

Lines changed: 67 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Render } from '@qwik.dev/core/server';
22
import type { RendererOptions } from '@qwik.dev/router';
3-
import type { Connect, ViteDevServer } from 'vite';
3+
import { promises as fs } from 'node:fs';
4+
import { preprocessCSS } from 'vite';
5+
import type { Connect, HtmlTagDescriptor, ViteDevServer } from 'vite';
46
import { updateRoutingContext } from '../build';
57
import type { RoutingContext } from '../types';
68
import { formatError } from './format-error';
@@ -99,93 +101,82 @@ const CSS_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus'];
99101
const JS_EXTENSIONS = /\.[mc]?[tj]sx?$/;
100102
const isCssPath = (url: string) => CSS_EXTENSIONS.some((ext) => url.endsWith(ext));
101103

102-
/**
103-
* Qwik handles CSS imports itself, meaning vite doesn't get to see them, so we need to manually
104-
* inject the CSS URLs.
105-
*
106-
* We check both the client and SSR module graphs because on the first page request only the SSR
107-
* graph has been populated (via ssrLoadModule). The client graph is empty until the browser
108-
* actually fetches modules through Vite's dev server.
109-
*/
110-
const getCssUrls = (server: ViteDevServer) => {
104+
interface RouterCssModule {
105+
id: string;
106+
file: string;
107+
url: string;
108+
}
109+
110+
const collectRouterCssModules = (server: ViteDevServer): RouterCssModule[] => {
111111
const clientGraph = server.environments.client.moduleGraph;
112112
const ssrGraph = server.environments.ssr.moduleGraph;
113-
const cssModules = new Set<string>();
114-
const cssImportedByCSS = new Set<string>();
113+
const byFile = new Map<string, RouterCssModule>();
115114

116115
for (const graph of [clientGraph, ssrGraph]) {
117116
for (const mod of graph.idToModuleMap.values()) {
118117
const [pathId, query] = mod.url.split('?');
119-
120-
if (!query && isCssPath(pathId)) {
121-
const isEntryCSS = mod.importers.size === 0;
122-
const hasCSSImporter = Array.from(mod.importers).some((importer) => {
123-
const importerPath = importer.url || importer.file;
124-
125-
const isCSS = importerPath && isCssPath(importerPath);
126-
127-
if (isCSS && mod.url) {
128-
cssImportedByCSS.add(mod.url);
129-
}
130-
131-
return isCSS;
132-
});
133-
134-
const hasJSImporter = Array.from(mod.importers).some((importer) => {
135-
const importerPath = importer.url || importer.file;
136-
return importerPath && JS_EXTENSIONS.test(importerPath);
137-
});
138-
139-
if ((isEntryCSS || hasJSImporter) && !hasCSSImporter && !cssImportedByCSS.has(mod.url)) {
140-
cssModules.add(`${mod.url}${mod.lastHMRTimestamp ? `?t=${mod.lastHMRTimestamp}` : ''}`);
141-
// SSR-only CSS isn't watched by Vite; watch it so edits fire handleHotUpdate.
142-
if (mod.file) {
143-
server.watcher.add(mod.file);
144-
}
118+
if (query || !isCssPath(pathId) || !mod.file || !mod.id) {
119+
continue;
120+
}
121+
const isEntryCSS = mod.importers.size === 0;
122+
let hasCSSImporter = false;
123+
let hasJSImporter = false;
124+
for (const importer of mod.importers) {
125+
const importerPath = importer.url || importer.file;
126+
if (!importerPath) {
127+
continue;
128+
}
129+
if (isCssPath(importerPath)) {
130+
hasCSSImporter = true;
131+
} else if (JS_EXTENSIONS.test(importerPath)) {
132+
hasJSImporter = true;
145133
}
146134
}
135+
if ((isEntryCSS || hasJSImporter) && !hasCSSImporter && !byFile.has(mod.file)) {
136+
byFile.set(mod.file, { id: mod.id, file: mod.file, url: mod.url });
137+
}
147138
}
148139
}
149-
return [...cssModules];
140+
return [...byFile.values()];
150141
};
151142

152-
export const getRouterIndexTags = (server: ViteDevServer) => {
153-
const cssUrls = getCssUrls(server);
154-
return cssUrls.map((url) => ({
155-
tag: 'link',
156-
attrs: { rel: 'stylesheet', href: url },
157-
}));
143+
export const buildRouterCssTags = (
144+
cssModules: { id: string; url: string; css: string }[]
145+
): HtmlTagDescriptor[] => {
146+
const tags: HtmlTagDescriptor[] = [];
147+
for (const { id, url, css } of cssModules) {
148+
if (css) {
149+
tags.push({
150+
tag: 'style',
151+
attrs: { 'data-vite-dev-id': id },
152+
children: css,
153+
injectTo: 'head',
154+
});
155+
} else {
156+
tags.push({ tag: 'link', attrs: { rel: 'stylesheet', href: url }, injectTo: 'head' });
157+
}
158+
tags.push({
159+
tag: 'script',
160+
attrs: { type: 'module' },
161+
children: `import ${JSON.stringify(url)}`,
162+
injectTo: 'head',
163+
});
164+
}
165+
return tags;
158166
};
159167

160-
/**
161-
* Live-reload route CSS that Qwik injects as `<link>` tags; it only lives in the SSR graph, so Vite
162-
* skips HMR. Emit a `css-update` to swap the `<link>` in place. Returns `true` when handled.
163-
*/
164-
export const sendRouterCssHotUpdate = (
165-
server: ViteDevServer,
166-
file: string,
167-
timestamp: number
168-
): boolean => {
169-
const { client, ssr } = server.environments;
170-
if (!isCssPath(file) || client.moduleGraph.getModulesByFile(file)?.size) {
171-
return false;
172-
}
173-
const paths = new Set<string>();
174-
for (const mod of ssr.moduleGraph.getModulesByFile(file) ?? []) {
175-
mod.lastHMRTimestamp = timestamp; // keep getCssUrls' cache-busting query fresh on full reloads
176-
paths.add(mod.url.split('?')[0]);
177-
}
178-
if (!paths.size) {
179-
return false;
180-
}
181-
client.hot.send({
182-
type: 'update',
183-
updates: [...paths].map((path) => ({
184-
type: 'css-update' as const,
185-
path,
186-
acceptedPath: path,
187-
timestamp,
188-
})),
189-
});
190-
return true;
168+
export const getRouterIndexTags = async (server: ViteDevServer): Promise<HtmlTagDescriptor[]> => {
169+
const cssModules = await Promise.all(
170+
collectRouterCssModules(server).map(async ({ id, file, url }) => {
171+
let css = '';
172+
try {
173+
const raw = await fs.readFile(file, 'utf-8');
174+
css = (await preprocessCSS(raw, file, server.config)).code;
175+
} catch {
176+
// no styles inlined; a <link> is emitted instead
177+
}
178+
return { id, url, css };
179+
})
180+
);
181+
return buildRouterCssTags(cssModules);
191182
};

packages/qwik-router/src/buildtime/vite/plugin.ts

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { basename, extname, join, resolve } from 'node:path';
55
import type {
66
ConfigEnv,
77
EnvironmentOptions,
8-
HmrContext,
8+
HotUpdateOptions,
99
Plugin,
1010
PluginOption,
1111
Rolldown,
@@ -42,11 +42,7 @@ import type {
4242
QwikRouterVitePluginOptions,
4343
} from './types';
4444
import { validatePlugin } from './validate-plugin';
45-
import {
46-
getRouterIndexTags,
47-
makeRouterDevMiddleware,
48-
sendRouterCssHotUpdate,
49-
} from './dev-middleware';
45+
import { getRouterIndexTags, makeRouterDevMiddleware } from './dev-middleware';
5046

5147
export const QWIK_ROUTER_CONFIG_ID = '@qwik-router-config';
5248
/**
@@ -273,12 +269,6 @@ function qwikRouterPlugin(
273269
'zod',
274270
],
275271
},
276-
server: {
277-
watch: {
278-
// needed for recursive watching of index and layout files in the src/routes directory
279-
disableGlobbing: false,
280-
},
281-
},
282272
};
283273
return updatedViteConfig;
284274
},
@@ -354,14 +344,7 @@ function qwikRouterPlugin(
354344

355345
async configureServer(server) {
356346
devServer = server;
357-
// recursively watch all route files in the src/routes directory
358-
const toWatch = [
359-
join(
360-
ctx!.opts.routesDir,
361-
'**/{index,index!,index@*,layout,layout!,layout-*,error,404,entry,service-worker,menu}.{ts,tsx,js,jsx,md,mdx}'
362-
),
363-
join(ctx!.opts.serverPluginsDir, 'plugin{,@*}.{ts,tsx,js,jsx}'),
364-
];
347+
const toWatch = [ctx!.opts.routesDir, ctx!.opts.serverPluginsDir];
365348
server.watcher.add(toWatch);
366349
await new Promise((resolve) => setTimeout(resolve, 1000));
367350
server.watcher.on('change', (path) => {
@@ -381,25 +364,26 @@ function qwikRouterPlugin(
381364
}
382365
},
383366

384-
handleHotUpdate({ file, modules, server, timestamp }: HmrContext) {
385-
// Route CSS is injected as a <link>; swap it in place rather than forcing a restart.
386-
if (sendRouterCssHotUpdate(server, file, timestamp)) {
387-
return [];
388-
}
389-
if (!ctx || !isRouterSourceFileForContext(file, ctx)) {
367+
hotUpdate({ file, modules }: HotUpdateOptions) {
368+
const server = devServer;
369+
if (!server || !ctx || !isRouterSourceFileForContext(file, ctx)) {
390370
return;
391371
}
392372
clearRouteLoaderHashes(loadersByFile, file);
393373
ctx.isDirty = true;
394-
const configModules = invalidateRouterConfigModules(server);
395-
return [...modules, ...configModules];
374+
invalidateRouterConfigModules(server);
375+
const configModule = this.environment.moduleGraph.getModuleById(QWIK_ROUTER_CONFIG_ID);
376+
return configModule ? [...modules, configModule] : modules;
396377
},
397378

398-
transformIndexHtml() {
399-
if (viteCommand !== 'serve') {
400-
return;
401-
}
402-
return getRouterIndexTags(devServer!);
379+
transformIndexHtml: {
380+
order: 'pre',
381+
async handler() {
382+
if (viteCommand !== 'serve') {
383+
return;
384+
}
385+
return getRouterIndexTags(devServer!);
386+
},
403387
},
404388

405389
buildStart() {

packages/qwik-vite/src/plugins/plugin.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
isServerOnlyModule,
2626
mightContainServerOnlyImport,
2727
} from './server-only-modules';
28-
import { isVirtualId, isWin, parseId, sanitizeChunkGroupName } from './vite-utils';
28+
import { isVirtualId, isWin, parseId, sanitizeChunkGroupName, toDevPath } from './vite-utils';
2929
import MagicString from 'magic-string';
3030
import {
3131
createDevWorkerQrlChunkResolver,
@@ -963,14 +963,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) {
963963
// Fallback: if the module isn't in the graph yet (first transform),
964964
// compute a root-relative URL path that matches what hotUpdate sends.
965965
if (!devPath && opts.rootDir) {
966-
const rootDir = normalizePath(opts.rootDir);
967-
const normalizedId = normalizePath(pathId);
968-
if (normalizedId.startsWith(rootDir)) {
969-
devPath = normalizedId.slice(rootDir.length);
970-
if (!devPath.startsWith('/')) {
971-
devPath = '/' + devPath;
972-
}
973-
}
966+
devPath = toDevPath(normalizePath(pathId), normalizePath(opts.rootDir));
974967
}
975968
}
976969
const transformOpts: TransformModulesOptions = {

0 commit comments

Comments
 (0)