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, ),