Skip to content

Commit 73a6b5d

Browse files
committed
fix(router): hot-reload CSS imported by route files
CSS imported by route files (index/layout) is resolved by Qwik and injected as a <link>, so it only lives in the SSR module graph. Vite watches its own client graph plus the route source-file globs, never this CSS, so edits produced no HMR event and styles stayed stale until a dev server restart. CSS imported by client-loaded components was unaffected because Vite handles those natively. Register the injected CSS files with the watcher and, on change, emit a css-update so Vite's client swaps the <link> in place. CSS already in the client graph is left to Vite's native HMR.
1 parent 9c4f0a9 commit 73a6b5d

4 files changed

Lines changed: 103 additions & 2 deletions

File tree

.changeset/router-css-hmr.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': patch
3+
---
4+
5+
fix: hot-reload CSS imported by route files in dev. Such CSS is resolved by Qwik and injected as a `<link>`, so Vite never watched it and edits required a server restart. The router now watches these files and emits a `css-update` so the stylesheet swaps in place.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { ViteDevServer } from 'vite';
3+
import { sendRouterCssHotUpdate } from './dev-middleware';
4+
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 }[]) => ({ getModulesByFile: () => (mods ? new Set(mods) : undefined) });
11+
const server = {
12+
environments: {
13+
client: { moduleGraph: graph(opts.client), hot: { send } },
14+
ssr: { moduleGraph: graph(opts.ssr) },
15+
},
16+
} as unknown as ViteDevServer;
17+
return { server, send };
18+
}
19+
20+
describe('sendRouterCssHotUpdate', () => {
21+
it('ignores non-CSS files', () => {
22+
const { server, send } = makeServer({ ssr: [{ url: '/x.tsx' }] });
23+
expect(sendRouterCssHotUpdate(server, '/app/src/routes/index.tsx', 1)).toBe(false);
24+
expect(send).not.toHaveBeenCalled();
25+
});
26+
27+
it('emits a deduped css-update for route CSS that only lives in the SSR graph', () => {
28+
const { server, send } = makeServer({
29+
ssr: [{ url: '/src/routes/docs/docs.css' }, { url: '/src/routes/docs/docs.css?inline' }],
30+
});
31+
expect(sendRouterCssHotUpdate(server, file, 123)).toBe(true);
32+
expect(send).toHaveBeenCalledWith({
33+
type: 'update',
34+
updates: [
35+
{
36+
type: 'css-update',
37+
path: '/src/routes/docs/docs.css',
38+
acceptedPath: '/src/routes/docs/docs.css',
39+
timestamp: 123,
40+
},
41+
],
42+
});
43+
});
44+
45+
it('defers to Vite when the CSS is already in the client graph', () => {
46+
const { server, send } = makeServer({
47+
client: [{ url: '/src/routes/docs/docs.css' }],
48+
ssr: [{ url: '/src/routes/docs/docs.css' }],
49+
});
50+
expect(sendRouterCssHotUpdate(server, file, 1)).toBe(false);
51+
expect(send).not.toHaveBeenCalled();
52+
});
53+
});

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ const getCssUrls = (server: ViteDevServer) => {
138138

139139
if ((isEntryCSS || hasJSImporter) && !hasCSSImporter && !cssImportedByCSS.has(mod.url)) {
140140
cssModules.add(`${mod.url}${mod.lastHMRTimestamp ? `?t=${mod.lastHMRTimestamp}` : ''}`);
141+
// These files only live in the SSR graph, so Vite doesn't watch them. Watch explicitly
142+
// so edits fire handleHotUpdate -> sendRouterCssHotUpdate instead of needing a restart.
143+
if (mod.file) {
144+
server.watcher.add(mod.file);
145+
}
141146
}
142147
}
143148
}
@@ -152,3 +157,33 @@ export const getRouterIndexTags = (server: ViteDevServer) => {
152157
attrs: { rel: 'stylesheet', href: url },
153158
}));
154159
};
160+
161+
/**
162+
* Live-reload route CSS that Qwik injects as `<link>` tags (see {@link getCssUrls}). Qwik resolves
163+
* these imports itself, so they only exist in the SSR graph; Vite finds no client module and skips
164+
* HMR, leaving stale styles until a restart. We emit a `css-update` so Vite's client swaps the
165+
* `<link>` in place. Returns `true` when handled; CSS in the client graph is left to Vite's HMR.
166+
*/
167+
export const sendRouterCssHotUpdate = (
168+
server: ViteDevServer,
169+
file: string,
170+
timestamp: number
171+
): boolean => {
172+
const { client, ssr } = server.environments;
173+
if (!isCssPath(file) || client.moduleGraph.getModulesByFile(file)?.size) {
174+
return false;
175+
}
176+
const paths = new Set<string>();
177+
for (const mod of ssr.moduleGraph.getModulesByFile(file) ?? []) {
178+
mod.lastHMRTimestamp = timestamp; // keep getCssUrls' cache-busting query fresh on full reloads
179+
paths.add(mod.url.split('?')[0]);
180+
}
181+
if (!paths.size) {
182+
return false;
183+
}
184+
client.hot.send({
185+
type: 'update',
186+
updates: [...paths].map((path) => ({ type: 'css-update' as const, path, acceptedPath: path, timestamp })),
187+
});
188+
return true;
189+
};

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ import type {
4141
QwikRouterVitePluginOptions,
4242
} from './types';
4343
import { validatePlugin } from './validate-plugin';
44-
import { getRouterIndexTags, makeRouterDevMiddleware } from './dev-middleware';
44+
import {
45+
getRouterIndexTags,
46+
makeRouterDevMiddleware,
47+
sendRouterCssHotUpdate,
48+
} from './dev-middleware';
4549

4650
export const QWIK_ROUTER_CONFIG_ID = '@qwik-router-config';
4751
/**
@@ -366,7 +370,11 @@ function qwikRouterPlugin(
366370
}
367371
},
368372

369-
handleHotUpdate({ file, modules, server }: HmrContext) {
373+
handleHotUpdate({ file, modules, server, timestamp }: HmrContext) {
374+
// Route CSS is injected as a <link>; swap it in place rather than forcing a restart.
375+
if (sendRouterCssHotUpdate(server, file, timestamp)) {
376+
return [];
377+
}
370378
if (!ctx || !isRouterSourceFileForContext(file, ctx)) {
371379
return;
372380
}

0 commit comments

Comments
 (0)