Skip to content

Commit 9e422e0

Browse files
committed
fix: retain CSS for dynamic imports on navigation
1 parent 900636f commit 9e422e0

File tree

3 files changed

+225
-1
lines changed

3 files changed

+225
-1
lines changed

.changeset/hip-foxes-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Ensure route navigation doesn't inadvertently remove CSS `link` elements injected by dynamic imports
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
4+
import {
5+
type Fixture,
6+
type AppFixture,
7+
createAppFixture,
8+
createFixture,
9+
css,
10+
js,
11+
} from "./helpers/create-fixture.js";
12+
13+
test.describe("Vite CSS lazy loading", () => {
14+
let fixture: Fixture;
15+
let appFixture: AppFixture;
16+
17+
test.beforeAll(async () => {
18+
fixture = await createFixture({
19+
files: {
20+
"app/components/css-component.module.css": css`
21+
.test {
22+
color: rgb(0, 128, 0);
23+
font-family: sans-serif;
24+
font-weight: bold;
25+
}
26+
`,
27+
28+
"app/components/css-component.tsx": js`
29+
import styles from "./css-component.module.css";
30+
export default function CssComponent() {
31+
return <p data-css-component className={styles.test}>This text should be green.</p>;
32+
}
33+
`,
34+
35+
"app/components/load-lazy-css-component.tsx": js`
36+
import { lazy, useState } from "react";
37+
const LazyCssComponent = lazy(() => import("./css-component"));
38+
export function LoadLazyCssComponent() {
39+
const [show, setShow] = useState(false);
40+
return (
41+
<>
42+
<button data-load-lazy-css-component onClick={() => setShow(true)}>Load Lazy CSS Component</button>
43+
{show && <LazyCssComponent />}
44+
</>
45+
);
46+
}
47+
`,
48+
49+
"app/routes/_layout.tsx": js`
50+
import { Link, Outlet } from "react-router";
51+
import { LoadLazyCssComponent } from "../components/load-lazy-css-component";
52+
export default function Layout() {
53+
return (
54+
<>
55+
<nav>
56+
<ul>
57+
<li>
58+
<Link to="/">Home</Link>
59+
</li>
60+
<li>
61+
<Link to="/with-css-component">Route with CSS Component</Link>
62+
</li>
63+
<li>
64+
<Link to="/without-css-component">Route Without CSS Component</Link>
65+
</li>
66+
</ul>
67+
</nav>
68+
<LoadLazyCssComponent />
69+
<Outlet />
70+
</>
71+
);
72+
}
73+
`,
74+
75+
"app/routes/_layout._index.tsx": js`
76+
export default function Index() {
77+
return <h2 data-route-home>Home</h2>;
78+
}
79+
`,
80+
81+
"app/routes/_layout.with-css-component.tsx": js`
82+
import CssComponent from "../components/css-component";
83+
export default function RouteWithCssComponent() {
84+
return (
85+
<>
86+
<h2 data-route-with-css-component>Route with CSS Component</h2>
87+
<CssComponent />
88+
</>
89+
);
90+
}
91+
`,
92+
93+
"app/routes/_layout.without-css-component.tsx": js`
94+
export default function RouteWithoutCssComponent() {
95+
return <h2 data-route-without-css-component>Route Without CSS Component</h2>;
96+
}
97+
`,
98+
},
99+
});
100+
101+
appFixture = await createAppFixture(fixture);
102+
});
103+
104+
test.afterAll(() => {
105+
appFixture.close();
106+
});
107+
108+
test("retains CSS from dynamic imports on navigation if the same CSS is also imported by a route", async ({
109+
page,
110+
}) => {
111+
let app = new PlaywrightFixture(appFixture, page);
112+
113+
const ANY_CSS_LINK_SELECTOR =
114+
"link[rel='stylesheet'][href*='css-component']";
115+
// Links with a trailing hash are only ever managed by React Router, not
116+
// Vite's dynamic CSS injection logic
117+
const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`;
118+
119+
function getCssComponentColor() {
120+
return page
121+
.locator("[data-css-component]")
122+
.evaluate((el) => window.getComputedStyle(el).color);
123+
}
124+
125+
await app.goto("/with-css-component");
126+
await page.waitForSelector("[data-route-with-css-component]");
127+
expect(await page.locator("[data-css-component]").count()).toBe(1);
128+
expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1);
129+
expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1);
130+
131+
expect(await getCssComponentColor()).toBe("rgb(0, 128, 0)");
132+
133+
await page.locator("[data-load-lazy-css-component]").click();
134+
await page.waitForSelector("[data-css-component]");
135+
expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(2);
136+
expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1);
137+
138+
await app.clickLink("/without-css-component");
139+
await page.waitForSelector("[data-route-without-css-component]");
140+
expect(await page.locator("[data-css-component]").count()).toBe(1);
141+
expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1);
142+
expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0);
143+
144+
expect(await getCssComponentColor()).toBe("rgb(0, 128, 0)");
145+
});
146+
});

packages/react-router-dev/vite/plugin.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ const getReactRouterManifestBuildAssets = (
346346
ctx: ReactRouterPluginContext,
347347
viteConfig: Vite.ResolvedConfig,
348348
viteManifest: Vite.Manifest,
349+
allDynamicCssFiles: Set<string>,
349350
entryFilePath: string,
350351
route: RouteManifestEntry | null,
351352
): ReactRouterManifest["entry"] & { css: string[] } => {
@@ -394,7 +395,22 @@ const getReactRouterManifestBuildAssets = (
394395
: null,
395396
chunks
396397
.flatMap((e) => e.css ?? [])
397-
.map((href) => `${ctx.publicPath}${href}`),
398+
.map((href) => {
399+
let publicHref = `${ctx.publicPath}${href}`;
400+
// If this CSS file is also dynamically imported anywhere in the
401+
// application, we append a hash to the href so Vite ignores it when
402+
// managing dynamic CSS injection. If we don't do this, Vite's
403+
// dynamic import logic might hold off on inserting a new `link`
404+
// element because it's already in the page, only for React Router
405+
// to remove it when navigating to a new route, resulting in missing
406+
// styles. By appending a hash, Vite doesn't detect that the CSS is
407+
// already in the page and always manages its own `link` element.
408+
// This means that Vite's CSS stays in the page even if the
409+
// route-level CSS is removed from the document. We use a hash here
410+
// because it's a unique `href` value but isn't a unique network
411+
// request and only adds a single character.
412+
return allDynamicCssFiles.has(href) ? `${publicHref}#` : publicHref;
413+
}),
398414
]
399415
.flat(1)
400416
.filter(isNonNullable),
@@ -429,6 +445,59 @@ function resolveDependantChunks(
429445
return Array.from(chunks);
430446
}
431447

448+
function getAllDynamicCssFiles(
449+
ctx: ReactRouterPluginContext,
450+
viteManifest: Vite.Manifest,
451+
): Set<string> {
452+
let allDynamicCssFiles = new Set<string>();
453+
454+
for (let route of Object.values(ctx.reactRouterConfig.routes)) {
455+
let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file);
456+
let entryChunk = resolveChunk(
457+
ctx,
458+
viteManifest,
459+
`${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`,
460+
);
461+
462+
if (entryChunk) {
463+
let visitedChunks = new Set<Vite.ManifestChunk>();
464+
465+
function walk(
466+
chunk: Vite.ManifestChunk,
467+
isDynamicImportContext: boolean,
468+
) {
469+
if (visitedChunks.has(chunk)) {
470+
return;
471+
}
472+
473+
visitedChunks.add(chunk);
474+
475+
if (isDynamicImportContext && chunk.css) {
476+
for (let cssFile of chunk.css) {
477+
allDynamicCssFiles.add(cssFile);
478+
}
479+
}
480+
481+
if (chunk.dynamicImports) {
482+
for (let dynamicImportKey of chunk.dynamicImports) {
483+
walk(viteManifest[dynamicImportKey], true);
484+
}
485+
}
486+
487+
if (chunk.imports) {
488+
for (let importKey of chunk.imports) {
489+
walk(viteManifest[importKey], isDynamicImportContext);
490+
}
491+
}
492+
}
493+
494+
walk(entryChunk, false);
495+
}
496+
}
497+
498+
return allDynamicCssFiles;
499+
}
500+
432501
function dedupe<T>(array: T[]): T[] {
433502
return [...new Set(array)];
434503
}
@@ -886,10 +955,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
886955
getClientBuildDirectory(ctx.reactRouterConfig),
887956
);
888957

958+
let allDynamicCssFiles = getAllDynamicCssFiles(ctx, viteManifest);
959+
889960
let entry = getReactRouterManifestBuildAssets(
890961
ctx,
891962
viteConfig,
892963
viteManifest,
964+
allDynamicCssFiles,
893965
ctx.entryClientFilePath,
894966
null,
895967
);
@@ -953,6 +1025,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
9531025
ctx,
9541026
viteConfig,
9551027
viteManifest,
1028+
allDynamicCssFiles,
9561029
`${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`,
9571030
route,
9581031
),

0 commit comments

Comments
 (0)