diff --git a/.changeset/hip-foxes-repeat.md b/.changeset/hip-foxes-repeat.md
new file mode 100644
index 0000000000..a146c2e380
--- /dev/null
+++ b/.changeset/hip-foxes-repeat.md
@@ -0,0 +1,5 @@
+---
+"@react-router/dev": patch
+---
+
+Ensure route navigation doesn't remove CSS `link` elements used by dynamic imports
diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts
new file mode 100644
index 0000000000..21c444693d
--- /dev/null
+++ b/integration/vite-css-lazy-loading-test.ts
@@ -0,0 +1,280 @@
+import { type Page, test, expect } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import {
+ type Fixture,
+ type AppFixture,
+ createAppFixture,
+ createFixture,
+ css,
+ js,
+} from "./helpers/create-fixture.js";
+
+// Link hrefs with a trailing hash are only ever managed by React Router, to
+// ensure they're forcibly unique from the Vite-injected links
+const FORCIBLY_UNIQUE_HREF_SELECTOR = "[href$='#']";
+const CSS_LINK_SELECTOR = "link[rel='stylesheet']";
+const ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR = `link[rel='stylesheet']${FORCIBLY_UNIQUE_HREF_SELECTOR}`;
+const CSS_COMPONENT_LINK_SELECTOR =
+ "link[rel='stylesheet'][href*='css-component']";
+const CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR = `link[rel='stylesheet'][href*='css-component']${FORCIBLY_UNIQUE_HREF_SELECTOR}`;
+
+function getColor(page: Page, selector: string) {
+ return page
+ .locator(selector)
+ .first()
+ .evaluate((el) => window.getComputedStyle(el).color);
+}
+
+test.describe("Vite CSS lazy loading", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ "app/components/css-component.css": css`
+ .css-component {
+ color: rgb(0, 128, 0);
+ font-family: sans-serif;
+ font-weight: bold;
+ }
+ `,
+
+ "app/components/css-component.tsx": js`
+ import "./css-component.css";
+ export default function CssComponent() {
+ return
This text should be green.
;
+ }
+ `,
+
+ "app/components/static-only-css-component.css": css`
+ .static-only-css-component {
+ color: rgb(128, 128, 0);
+ font-family: sans-serif;
+ font-weight: bold;
+ }
+ `,
+
+ "app/components/static-only-css-component.tsx": js`
+ import "./static-only-css-component.css";
+ export default function StaticOnlyCssComponent() {
+ return This text should be olive.
;
+ }
+ `,
+
+ "app/components/load-lazy-css-component.tsx": js`
+ import { lazy, useState } from "react";
+ export const LazyCssComponent = lazy(() => import("./css-component"));
+ export function LoadLazyCssComponent() {
+ const [show, setShow] = useState(false);
+ return (
+ <>
+
+ {show && }
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.tsx": js`
+ import { Link, Outlet } from "react-router";
+ import { LoadLazyCssComponent } from "../components/load-lazy-css-component";
+ export default function Layout() {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout._index.tsx": js`
+ export default function Index() {
+ return Home
;
+ }
+ `,
+
+ "app/routes/_layout.parent-child.tsx": js`
+ import { Outlet } from "react-router";
+ import { LoadLazyCssComponent } from "../components/load-lazy-css-component";
+ export default function ParentChild() {
+ return (
+ <>
+ Parent + Child
+
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.parent-child.with-css-component.tsx": js`
+ import CssComponent from "../components/css-component";
+ export default function RouteWithCssComponent() {
+ return (
+ <>
+ Route with CSS Component
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.parent-child.without-css-component.tsx": js`
+ export default function RouteWithoutCssComponent() {
+ return Route Without CSS Component
;
+ }
+ `,
+
+ "app/routes/_layout.siblings.tsx": js`
+ import { Outlet } from "react-router";
+ export default function Siblings() {
+ return (
+ <>
+ Siblings
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.siblings.with-css-component.tsx": js`
+ import CssComponent from "../components/css-component";
+ export default function SiblingsWithCssComponent() {
+ return (
+ <>
+ Route with CSS Component
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.siblings.with-lazy-css-component.tsx": js`
+ import { LazyCssComponent } from "../components/load-lazy-css-component";
+ export default function SiblingsWithLazyCssComponent() {
+ return (
+ <>
+ Route with Lazy CSS Component
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/_layout.with-static-only-css-component.tsx": js`
+ import StaticOnlyCssComponent from "../components/static-only-css-component";
+ export default function WithStaticOnlyCssComponent() {
+ return (
+ <>
+ Route with Static Only CSS Component
+
+ >
+ );
+ }
+ `,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => {
+ appFixture.close();
+ });
+
+ test("retains CSS from dynamic imports in a parent route on navigation if the same CSS is a static dependency of a child route", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/parent-child/with-css-component");
+ await page.waitForSelector("[data-child-route-with-css-component]");
+ expect(await page.locator("[data-css-component]").count()).toBe(1);
+ expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1);
+ expect(
+ await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(),
+ ).toBe(1);
+ expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)");
+
+ await page.locator("[data-load-lazy-css-component]").click();
+ await page.waitForSelector("[data-css-component]");
+ expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(2);
+ expect(
+ await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(),
+ ).toBe(1);
+ expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)");
+
+ await app.clickLink("/parent-child/without-css-component");
+ await page.waitForSelector("[data-child-route-without-css-component]");
+ expect(await page.locator("[data-css-component]").count()).toBe(1);
+ expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1);
+ expect(
+ await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(),
+ ).toBe(0);
+ expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)");
+ });
+
+ test("supports CSS lazy loading when navigating to a sibling route if the current route has a static dependency on the same CSS", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/siblings/with-css-component");
+ await page.waitForSelector("[data-sibling-route-with-css-component]");
+ expect(await page.locator("[data-css-component]").count()).toBe(1);
+ expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1);
+ expect(
+ await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(),
+ ).toBe(1);
+ expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)");
+
+ await app.clickLink("/siblings/with-lazy-css-component");
+ await page.waitForSelector("[data-sibling-route-with-lazy-css-component]");
+ expect(await page.locator("[data-css-component]").count()).toBe(1);
+ expect(await page.locator(CSS_COMPONENT_LINK_SELECTOR).count()).toBe(1);
+ expect(
+ await page.locator(CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR).count(),
+ ).toBe(0);
+ expect(await getColor(page, "[data-css-component]")).toBe("rgb(0, 128, 0)");
+ });
+
+ test("does not add a hash to the CSS link if the CSS is only ever statically imported", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/with-static-only-css-component");
+ await page.waitForSelector("[data-route-with-static-only-css-component]");
+ expect(await page.locator(CSS_LINK_SELECTOR).count()).toBe(1);
+ expect(
+ await page.locator(ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR).count(),
+ ).toBe(0);
+ expect(await getColor(page, "[data-static-only-css-component]")).toBe(
+ "rgb(128, 128, 0)",
+ );
+ });
+});
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index d409f808c0..82f87af822 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -346,6 +346,7 @@ const getReactRouterManifestBuildAssets = (
ctx: ReactRouterPluginContext,
viteConfig: Vite.ResolvedConfig,
viteManifest: Vite.Manifest,
+ allDynamicCssFiles: Set,
entryFilePath: string,
route: RouteManifestEntry | null,
): ReactRouterManifest["entry"] & { css: string[] } => {
@@ -394,7 +395,22 @@ const getReactRouterManifestBuildAssets = (
: null,
chunks
.flatMap((e) => e.css ?? [])
- .map((href) => `${ctx.publicPath}${href}`),
+ .map((href) => {
+ let publicHref = `${ctx.publicPath}${href}`;
+ // If this CSS file is also dynamically imported anywhere in the
+ // application, we append a hash to the href so Vite ignores it when
+ // managing dynamic CSS injection. If we don't do this, Vite's
+ // dynamic import logic might hold off on inserting a new `link`
+ // element because it's already in the page, only for React Router
+ // to remove it when navigating to a new route, resulting in missing
+ // styles. By appending a hash, Vite doesn't detect that the CSS is
+ // already in the page and always manages its own `link` element.
+ // This means that Vite's CSS stays in the page even if the
+ // route-level CSS is removed from the document. We use a hash here
+ // because it's a unique `href` value but isn't a unique network
+ // request and only adds a single character.
+ return allDynamicCssFiles.has(href) ? `${publicHref}#` : publicHref;
+ }),
]
.flat(1)
.filter(isNonNullable),
@@ -429,6 +445,59 @@ function resolveDependantChunks(
return Array.from(chunks);
}
+function getAllDynamicCssFiles(
+ ctx: ReactRouterPluginContext,
+ viteManifest: Vite.Manifest,
+): Set {
+ let allDynamicCssFiles = new Set();
+
+ for (let route of Object.values(ctx.reactRouterConfig.routes)) {
+ let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file);
+ let entryChunk = resolveChunk(
+ ctx,
+ viteManifest,
+ `${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`,
+ );
+
+ if (entryChunk) {
+ let visitedChunks = new Set();
+
+ function walk(
+ chunk: Vite.ManifestChunk,
+ isDynamicImportContext: boolean,
+ ) {
+ if (visitedChunks.has(chunk)) {
+ return;
+ }
+
+ visitedChunks.add(chunk);
+
+ if (isDynamicImportContext && chunk.css) {
+ for (let cssFile of chunk.css) {
+ allDynamicCssFiles.add(cssFile);
+ }
+ }
+
+ if (chunk.dynamicImports) {
+ for (let dynamicImportKey of chunk.dynamicImports) {
+ walk(viteManifest[dynamicImportKey], true);
+ }
+ }
+
+ if (chunk.imports) {
+ for (let importKey of chunk.imports) {
+ walk(viteManifest[importKey], isDynamicImportContext);
+ }
+ }
+ }
+
+ walk(entryChunk, false);
+ }
+ }
+
+ return allDynamicCssFiles;
+}
+
function dedupe(array: T[]): T[] {
return [...new Set(array)];
}
@@ -886,10 +955,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
getClientBuildDirectory(ctx.reactRouterConfig),
);
+ let allDynamicCssFiles = getAllDynamicCssFiles(ctx, viteManifest);
+
let entry = getReactRouterManifestBuildAssets(
ctx,
viteConfig,
viteManifest,
+ allDynamicCssFiles,
ctx.entryClientFilePath,
null,
);
@@ -953,6 +1025,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
ctx,
viteConfig,
viteManifest,
+ allDynamicCssFiles,
`${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`,
route,
),