Skip to content

Commit cd6dd5a

Browse files
authored
Merge pull request #8725 from briancarbone/fix/router-css-hmr
fix(qwik-router): hot-reload CSS imported by route files
2 parents c242a01 + 8586792 commit cd6dd5a

4 files changed

Lines changed: 107 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 route-imported CSS in dev without a server restart
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 }[]) => ({
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();
27+
});
28+
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+
],
44+
});
45+
});
46+
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();
54+
});
55+
});

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ 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+
// 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+
}
141145
}
142146
}
143147
}
@@ -152,3 +156,36 @@ export const getRouterIndexTags = (server: ViteDevServer) => {
152156
attrs: { rel: 'stylesheet', href: url },
153157
}));
154158
};
159+
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;
191+
};

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

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

4751
export const QWIK_ROUTER_CONFIG_ID = '@qwik-router-config';
4852
/**
@@ -373,7 +377,11 @@ function qwikRouterPlugin(
373377
}
374378
},
375379

376-
handleHotUpdate({ file, modules, server }: HmrContext) {
380+
handleHotUpdate({ file, modules, server, timestamp }: HmrContext) {
381+
// Route CSS is injected as a <link>; swap it in place rather than forcing a restart.
382+
if (sendRouterCssHotUpdate(server, file, timestamp)) {
383+
return [];
384+
}
377385
if (!ctx || !isRouterSourceFileForContext(file, ctx)) {
378386
return;
379387
}

0 commit comments

Comments
 (0)