From 9e422e04223ed4c5f91ac16c85584d4156e97dd4 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 21 Oct 2025 17:41:13 +1100 Subject: [PATCH 1/6] fix: retain CSS for dynamic imports on navigation --- .changeset/hip-foxes-repeat.md | 5 + integration/vite-css-lazy-loading-test.ts | 146 ++++++++++++++++++++++ packages/react-router-dev/vite/plugin.ts | 75 ++++++++++- 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .changeset/hip-foxes-repeat.md create mode 100644 integration/vite-css-lazy-loading-test.ts diff --git a/.changeset/hip-foxes-repeat.md b/.changeset/hip-foxes-repeat.md new file mode 100644 index 0000000000..09cdd08e62 --- /dev/null +++ b/.changeset/hip-foxes-repeat.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": patch +--- + +Ensure route navigation doesn't inadvertently remove CSS `link` elements injected 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..5635343524 --- /dev/null +++ b/integration/vite-css-lazy-loading-test.ts @@ -0,0 +1,146 @@ +import { 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"; + +test.describe("Vite CSS lazy loading", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/components/css-component.module.css": css` + .test { + color: rgb(0, 128, 0); + font-family: sans-serif; + font-weight: bold; + } + `, + + "app/components/css-component.tsx": js` + import styles from "./css-component.module.css"; + export default function CssComponent() { + return

This text should be green.

; + } + `, + + "app/components/load-lazy-css-component.tsx": js` + import { lazy, useState } from "react"; + 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.with-css-component.tsx": js` + import CssComponent from "../components/css-component"; + export default function RouteWithCssComponent() { + return ( + <> +

Route with CSS Component

+ + + ); + } + `, + + "app/routes/_layout.without-css-component.tsx": js` + export default function RouteWithoutCssComponent() { + return

Route Without CSS Component

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("retains CSS from dynamic imports on navigation if the same CSS is also imported by a route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + const ANY_CSS_LINK_SELECTOR = + "link[rel='stylesheet'][href*='css-component']"; + // Links with a trailing hash are only ever managed by React Router, not + // Vite's dynamic CSS injection logic + const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`; + + function getCssComponentColor() { + return page + .locator("[data-css-component]") + .evaluate((el) => window.getComputedStyle(el).color); + } + + await app.goto("/with-css-component"); + await page.waitForSelector("[data-route-with-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); + + expect(await getCssComponentColor()).toBe("rgb(0, 128, 0)"); + + await page.locator("[data-load-lazy-css-component]").click(); + await page.waitForSelector("[data-css-component]"); + expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(2); + expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); + + await app.clickLink("/without-css-component"); + await page.waitForSelector("[data-route-without-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); + + expect(await getCssComponentColor()).toBe("rgb(0, 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, ), From 1454c3c0fda0ed212f23c5d3cfa123e5c9c75016 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 22 Oct 2025 11:28:22 +1100 Subject: [PATCH 2/6] Expand test to include sibling route navigations --- integration/vite-css-lazy-loading-test.ts | 133 +++++++++++++++++----- 1 file changed, 105 insertions(+), 28 deletions(-) diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts index 5635343524..ad0cb30a98 100644 --- a/integration/vite-css-lazy-loading-test.ts +++ b/integration/vite-css-lazy-loading-test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { type Page, test, expect } from "@playwright/test"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; import { @@ -10,6 +10,17 @@ import { js, } from "./helpers/create-fixture.js"; +const ANY_CSS_LINK_SELECTOR = "link[rel='stylesheet'][href*='css-component']"; +// Links with a trailing hash are only ever managed by React Router, not +// Vite's dynamic CSS injection logic +const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`; + +function getCssComponentColor(page: Page) { + return page + .locator("[data-css-component]") + .evaluate((el) => window.getComputedStyle(el).color); +} + test.describe("Vite CSS lazy loading", () => { let fixture: Fixture; let appFixture: AppFixture; @@ -34,7 +45,7 @@ test.describe("Vite CSS lazy loading", () => { "app/components/load-lazy-css-component.tsx": js` import { lazy, useState } from "react"; - const LazyCssComponent = lazy(() => import("./css-component")); + export const LazyCssComponent = lazy(() => import("./css-component")); export function LoadLazyCssComponent() { const [show, setShow] = useState(false); return ( @@ -58,14 +69,19 @@ test.describe("Vite CSS lazy loading", () => { Home
  • - Route with CSS Component + Parent / Route with CSS Component +
  • +
  • + Parent / Route Without CSS Component
  • - Route Without CSS Component + Siblings / Route with CSS Component +
  • +
  • + Siblings / Route with Lazy CSS Component
  • - ); @@ -78,21 +94,71 @@ test.describe("Vite CSS lazy loading", () => { } `, - "app/routes/_layout.with-css-component.tsx": js` + "app/routes/_layout.parent-child.tsx": js` + import { Outlet } from "react-router"; + import { LoadLazyCssComponent } from "../components/load-lazy-css-component"; + export default function Parent() { + 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

    +

    Route with CSS Component

    ); } `, - "app/routes/_layout.without-css-component.tsx": js` + "app/routes/_layout.parent-child.without-css-component.tsx": js` export default function RouteWithoutCssComponent() { - return

    Route Without CSS Component

    ; + 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 ( + <> +

    Siblings / 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 ( + <> +

    Siblings / Route with Lazy CSS Component

    + + + ); } `, }, @@ -105,42 +171,53 @@ test.describe("Vite CSS lazy loading", () => { appFixture.close(); }); - test("retains CSS from dynamic imports on navigation if the same CSS is also imported by a route", async ({ + 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); - const ANY_CSS_LINK_SELECTOR = - "link[rel='stylesheet'][href*='css-component']"; - // Links with a trailing hash are only ever managed by React Router, not - // Vite's dynamic CSS injection logic - const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`; - - function getCssComponentColor() { - return page - .locator("[data-css-component]") - .evaluate((el) => window.getComputedStyle(el).color); - } - - await app.goto("/with-css-component"); - await page.waitForSelector("[data-route-with-css-component]"); + await app.goto("/parent-child/with-css-component"); + await page.waitForSelector("[data-route-child-with-css-component]"); expect(await page.locator("[data-css-component]").count()).toBe(1); expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await getCssComponentColor()).toBe("rgb(0, 128, 0)"); + expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); await page.locator("[data-load-lazy-css-component]").click(); await page.waitForSelector("[data-css-component]"); expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(2); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - await app.clickLink("/without-css-component"); - await page.waitForSelector("[data-route-without-css-component]"); + await app.clickLink("/parent-child/without-css-component"); + await page.waitForSelector("[data-route-child-without-css-component]"); expect(await page.locator("[data-css-component]").count()).toBe(1); expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); - expect(await getCssComponentColor()).toBe("rgb(0, 128, 0)"); + expect(await getCssComponentColor(page)).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-route-siblings-with-css-component]"); + expect(await page.locator("[data-css-component]").count()).toBe(1); + expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + + await app.clickLink("/siblings/with-lazy-css-component"); + await page.waitForSelector("[data-route-siblings-with-lazy-css-component]"); + + expect(await page.locator("[data-css-component]").count()).toBe(1); + + expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); + + expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); }); }); From 1fdf40b737fff9cd12a0b52c81fa2cb799de0cad Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 22 Oct 2025 11:35:18 +1100 Subject: [PATCH 3/6] Refactor test, add additional style check --- integration/vite-css-lazy-loading-test.ts | 33 +++++++++++------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts index ad0cb30a98..93e6cbf202 100644 --- a/integration/vite-css-lazy-loading-test.ts +++ b/integration/vite-css-lazy-loading-test.ts @@ -18,6 +18,7 @@ const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`; function getCssComponentColor(page: Page) { return page .locator("[data-css-component]") + .first() .evaluate((el) => window.getComputedStyle(el).color); } @@ -69,10 +70,10 @@ test.describe("Vite CSS lazy loading", () => { Home
  • - Parent / Route with CSS Component + Parent + Child / Route with CSS Component
  • - Parent / Route Without CSS Component + Parent + Child / Route Without CSS Component
  • Siblings / Route with CSS Component @@ -97,10 +98,10 @@ test.describe("Vite CSS lazy loading", () => { "app/routes/_layout.parent-child.tsx": js` import { Outlet } from "react-router"; import { LoadLazyCssComponent } from "../components/load-lazy-css-component"; - export default function Parent() { + export default function ParentChild() { return ( <> -

    Parent / Child

    +

    Parent + Child

    @@ -113,7 +114,7 @@ test.describe("Vite CSS lazy loading", () => { export default function RouteWithCssComponent() { return ( <> -

    Route with CSS Component

    +

    Route with CSS Component

    ); @@ -122,7 +123,7 @@ test.describe("Vite CSS lazy loading", () => { "app/routes/_layout.parent-child.without-css-component.tsx": js` export default function RouteWithoutCssComponent() { - return

    Route Without CSS Component

    ; + return

    Route Without CSS Component

    ; } `, @@ -131,7 +132,7 @@ test.describe("Vite CSS lazy loading", () => { export default function Siblings() { return ( <> -

    Siblings

    +

    Siblings

    ); @@ -143,7 +144,7 @@ test.describe("Vite CSS lazy loading", () => { export default function SiblingsWithCssComponent() { return ( <> -

    Siblings / Route with CSS Component

    +

    Route with CSS Component

    ); @@ -155,7 +156,7 @@ test.describe("Vite CSS lazy loading", () => { export default function SiblingsWithLazyCssComponent() { return ( <> -

    Siblings / Route with Lazy CSS Component

    +

    Route with Lazy CSS Component

    ); @@ -177,24 +178,23 @@ test.describe("Vite CSS lazy loading", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/parent-child/with-css-component"); - await page.waitForSelector("[data-route-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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); await page.locator("[data-load-lazy-css-component]").click(); await page.waitForSelector("[data-css-component]"); expect(await page.locator(ANY_CSS_LINK_SELECTOR).count()).toBe(2); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); + expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); await app.clickLink("/parent-child/without-css-component"); - await page.waitForSelector("[data-route-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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); }); @@ -204,20 +204,17 @@ test.describe("Vite CSS lazy loading", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/siblings/with-css-component"); - await page.waitForSelector("[data-route-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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); await app.clickLink("/siblings/with-lazy-css-component"); - await page.waitForSelector("[data-route-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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); }); }); From 6dcf099053fa3754f6f8747b3ea7ec57d525601d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 22 Oct 2025 14:24:32 +1100 Subject: [PATCH 4/6] Add test to ensure hash isn't added to static-only CSS --- integration/vite-css-lazy-loading-test.ts | 108 +++++++++++++++++----- 1 file changed, 83 insertions(+), 25 deletions(-) diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts index 93e6cbf202..f68ea3a51d 100644 --- a/integration/vite-css-lazy-loading-test.ts +++ b/integration/vite-css-lazy-loading-test.ts @@ -10,14 +10,16 @@ import { js, } from "./helpers/create-fixture.js"; -const ANY_CSS_LINK_SELECTOR = "link[rel='stylesheet'][href*='css-component']"; -// Links with a trailing hash are only ever managed by React Router, not -// Vite's dynamic CSS injection logic -const ROUTE_CSS_LINK_SELECTOR = `${ANY_CSS_LINK_SELECTOR}[href$='#']`; +const CSS_LINK_SELECTOR = "link[rel='stylesheet']"; +const CSS_COMPONENT_LINK_SELECTOR = `${CSS_LINK_SELECTOR}[href*='css-component']`; +// 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 ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR = `${CSS_LINK_SELECTOR}[href$='#']`; +const CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR = `${CSS_COMPONENT_LINK_SELECTOR}[href$='#']`; -function getCssComponentColor(page: Page) { +function getColor(page: Page, selector: string) { return page - .locator("[data-css-component]") + .locator(selector) .first() .evaluate((el) => window.getComputedStyle(el).color); } @@ -29,8 +31,8 @@ test.describe("Vite CSS lazy loading", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { - "app/components/css-component.module.css": css` - .test { + "app/components/css-component.css": css` + .css-component { color: rgb(0, 128, 0); font-family: sans-serif; font-weight: bold; @@ -38,9 +40,24 @@ test.describe("Vite CSS lazy loading", () => { `, "app/components/css-component.tsx": js` - import styles from "./css-component.module.css"; + import "./css-component.css"; export default function CssComponent() { - return

    This text should be green.

    ; + 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.

    ; } `, @@ -81,6 +98,9 @@ test.describe("Vite CSS lazy loading", () => {
  • Siblings / Route with Lazy CSS Component
  • +
  • + Route with Static Only CSS Component +
  • @@ -162,6 +182,18 @@ test.describe("Vite CSS lazy loading", () => { ); } `, + + "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

    + + + ); + } + `, }, }); @@ -180,22 +212,28 @@ test.describe("Vite CSS lazy loading", () => { 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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + 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(ANY_CSS_LINK_SELECTOR).count()).toBe(2); - expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + 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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + 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 ({ @@ -206,15 +244,35 @@ test.describe("Vite CSS lazy loading", () => { 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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + 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(ANY_CSS_LINK_SELECTOR).count()).toBe(1); - expect(await page.locator(ROUTE_CSS_LINK_SELECTOR).count()).toBe(0); - expect(await getCssComponentColor(page)).toBe("rgb(0, 128, 0)"); + 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)", + ); }); }); From a7c38fff4770442f94822cf3ec675d478daa19d2 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 22 Oct 2025 14:41:15 +1100 Subject: [PATCH 5/6] Update changeset --- .changeset/hip-foxes-repeat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/hip-foxes-repeat.md b/.changeset/hip-foxes-repeat.md index 09cdd08e62..a146c2e380 100644 --- a/.changeset/hip-foxes-repeat.md +++ b/.changeset/hip-foxes-repeat.md @@ -2,4 +2,4 @@ "@react-router/dev": patch --- -Ensure route navigation doesn't inadvertently remove CSS `link` elements injected by dynamic imports +Ensure route navigation doesn't remove CSS `link` elements used by dynamic imports From de3b900683a6b4cfadeb482b0516a1650f4a5f47 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 22 Oct 2025 14:47:49 +1100 Subject: [PATCH 6/6] Simplify test selectors --- integration/vite-css-lazy-loading-test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/integration/vite-css-lazy-loading-test.ts b/integration/vite-css-lazy-loading-test.ts index f68ea3a51d..21c444693d 100644 --- a/integration/vite-css-lazy-loading-test.ts +++ b/integration/vite-css-lazy-loading-test.ts @@ -10,12 +10,14 @@ import { js, } from "./helpers/create-fixture.js"; -const CSS_LINK_SELECTOR = "link[rel='stylesheet']"; -const CSS_COMPONENT_LINK_SELECTOR = `${CSS_LINK_SELECTOR}[href*='css-component']`; // 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 ANY_FORCIBLY_UNIQUE_CSS_LINK_SELECTOR = `${CSS_LINK_SELECTOR}[href$='#']`; -const CSS_COMPONENT_FORCIBLY_UNIQUE_LINK_SELECTOR = `${CSS_COMPONENT_LINK_SELECTOR}[href$='#']`; +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