diff --git a/.changeset/purple-papayas-applaud.md b/.changeset/purple-papayas-applaud.md new file mode 100644 index 0000000000..326bf49255 --- /dev/null +++ b/.changeset/purple-papayas-applaud.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Handle `meta` and `links` Route Exports in RSC Data Mode diff --git a/integration/helpers/rsc-vite-framework/tsconfig.json b/integration/helpers/rsc-vite-framework/tsconfig.json index 6b7a6b1e62..68287df9cf 100644 --- a/integration/helpers/rsc-vite-framework/tsconfig.json +++ b/integration/helpers/rsc-vite-framework/tsconfig.json @@ -14,6 +14,9 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client", "@vitejs/plugin-rsc/types"], "jsx": "react-jsx", + "paths": { + "~/*": ["./app/*"] + }, "rootDirs": [".", "./.react-router/types"] } } diff --git a/integration/link-test.ts b/integration/link-test.ts index a6640670c2..658466f04c 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -7,8 +7,14 @@ import { createAppFixture, } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { reactRouterConfig, type TemplateName } from "./helpers/vite.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +const templateNames = [ + "vite-5-template", + "rsc-vite-framework", +] as const satisfies TemplateName[]; + const fakeGists = [ { url: "https://api.github.com/gists/610613b54e5b34f8122d1ba4a3da21a9", @@ -27,599 +33,628 @@ const fakeGists = [ ]; test.describe("route module link export", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/favicon.ico": js``, - - "app/guitar.jpg": js``, - - "app/guitar-600.jpg": js``, - - "app/guitar-900.jpg": js``, - - "app/reset.css": css` - * { - box-sizing: border-box; - margin: 0; - padding: 0; - } - - html { - font-size: 16px; - box-sizing: border-box; - } - `, - - "app/app.css": css` - body { - background-color: #eee; - color: #000; - } - `, - - "app/gists.css": css` - * { - color: dodgerblue; - } - `, - - "app/redText.css": css` - * { - color: red; - } - `, - - "app/blueText.css": css` - * { - color: blue; - } - `, - - "app/root.tsx": js` - import { - Link, - Links, - Meta, - Outlet, - Scripts, - useRouteError, - isRouteErrorResponse - } from "react-router"; - import resetHref from "./reset.css?url"; - import stylesHref from "./app.css?url"; - import favicon from "./favicon.ico"; - - export function links() { - return [ - { rel: "stylesheet", href: resetHref }, - { rel: "stylesheet", href: stylesHref }, - { rel: "stylesheet", href: "/resources/theme-css" }, - { rel: "shortcut icon", href: favicon }, - ]; - } - - export let handle = { - breadcrumb: () => Home, - }; - - export default function Root() { - return ( - - - - - - - - - - - - ); - } - - export function ErrorBoundary() { - let error = useRouteError(); - - if (isRouteErrorResponse()) { - switch (error.status) { - case 404: - return ( - - - - 404 Not Found - - - -
-

404 Not Found

-
- - - - ); - default: - console.warn("Unexpected catch", error); - - return ( - - - - {error.status} Uh-oh! - - - -
-

- {error.status} {error.statusText} -

- {error.data ? ( -
-                              {JSON.stringify(error.data, null, 2)}
-                            
- ) : null} -
- - - - ); - } - } else { - console.error(error); + for (const templateName of templateNames) { + let fixture: Fixture; + let appFixture: AppFixture; + + test.describe(templateName, () => { + test.beforeAll(async () => { + fixture = await createFixture({ + templateName, + files: { + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName.includes("rsc"), + }), + + "app/favicon.ico": js``, + + "app/guitar.jpg": js``, + + "app/guitar-600.jpg": js``, + + "app/guitar-900.jpg": js``, + + "app/reset.css": css` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + font-size: 16px; + box-sizing: border-box; + } + `, + + "app/app.css": css` + body { + background-color: #eee; + color: #000; + } + `, + + "app/gists.css": css` + * { + color: dodgerblue; + } + `, + + "app/redText.css": css` + * { + color: red; + } + `, + + "app/blueText.css": css` + * { + color: blue; + } + `, + + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useRouteError, + isRouteErrorResponse + } from "react-router"; + import resetHref from "./reset.css?url"; + import stylesHref from "./app.css?url"; + import favicon from "./favicon.ico"; + + export function links() { + return [ + { rel: "stylesheet", href: resetHref }, + { rel: "stylesheet", href: stylesHref }, + { rel: "stylesheet", href: "/resources/theme-css" }, + { rel: "shortcut icon", href: favicon }, + ]; + } + + export let handle = { + breadcrumb: () => Home, + }; + + export default function Root() { return ( - Oops! + -
-

App Error Boundary

-
{error.message}
-
+ ); } - } - `, - - "app/routes/_index.tsx": js` - import { useEffect } from "react"; - import { Link } from "react-router"; - - export default function Index() { - return ( -
-
-

Cool App

-
- -
- ); - } - `, - - "app/routes/links.tsx": js` - import { useLoaderData, Link } from "react-router"; - import redTextHref from "~/redText.css?url"; - import blueTextHref from "~/blueText.css?url"; - import guitar from "~/guitar.jpg"; - export async function loader() { - return [ - { name: "Michael Jackson", id: "mjackson" }, - { name: "Ryan Florence", id: "ryanflorence" }, - ]; - } - export function links() { - return [ - { rel: "stylesheet", href: redTextHref }, - { - rel: "stylesheet", - href: blueTextHref, - media: "(prefers-color-scheme: beef)", - }, - { page: "/gists/mjackson" }, - { - rel: "preload", - as: "image", - href: guitar, - }, - ]; - } - export default function LinksPage() { - let users = useLoaderData(); - return ( -
-

Links Page

- {users.map((user) => ( -
  • - - {user.name} - -
  • - ))} -
    -

    - a guitar Prefetched - because it's a preload. -

    -
    - ); - } - `, - - "app/routes/responsive-image-preload.tsx": js` - import { Link } from "react-router"; - import guitar600 from "~/guitar-600.jpg"; - import guitar900 from "~/guitar-900.jpg"; - - export function links() { - return [ - { - rel: "preload", - as: "image", - imageSrcSet: guitar600 + " 600w, " + guitar900 + " 900w", - imageSizes: "100vw", - }, - ]; - } - export default function LinksPage() { - return ( -
    -

    Responsive Guitar

    -

    - a guitar{" "} - Prefetched because it's a preload. -

    -
    - ); - } - `, - - "app/routes/gists.tsx": js` - import { data, Link, Outlet, useLoaderData, useNavigation } from "react-router"; - import stylesHref from "~/gists.css?url"; - export function links() { - return [{ rel: "stylesheet", href: stylesHref }]; - } - export async function loader() { - return data({ - users: [ - { id: "ryanflorence", name: "Ryan Florence" }, - { id: "mjackson", name: "Michael Jackson" }, - ], - }, { - headers: { - "Cache-Control": "public, max-age=60", - }, - }); - } - export function headers({ loaderHeaders }) { - return { - "Cache-Control": loaderHeaders.get("Cache-Control"), - }; - } - export let handle = { - breadcrumb: () => Gists, - }; - export default function Gists() { - let locationPending = useNavigation().location; - let { users } = useLoaderData(); - return ( -
    -
    -

    Gists

    -
      + + export function ErrorBoundary() { + let error = useRouteError(); + + if (isRouteErrorResponse()) { + switch (error.status) { + case 404: + return ( + + + + 404 Not Found + + + +
      +

      404 Not Found

      +
      + + + + ); + default: + console.warn("Unexpected catch", error); + + return ( + + + + {error.status} Uh-oh! + + + +
      +

      + {error.status} {error.statusText} +

      + {error.data ? ( +
      +                                  {JSON.stringify(error.data, null, 2)}
      +                                
      + ) : null} +
      + + + + ); + } + } else { + console.error(error); + return ( + + + + Oops! + + + +
      +

      App Error Boundary

      +
      {error.message}
      +
      + + + + ); + } + } + `, + + "app/routes/_index.tsx": js` + import { useEffect } from "react"; + import { Link } from "react-router"; + + export default function Index() { + return ( +
      +
      +

      Cool App

      +
      + +
      + ); + } + `, + + "app/routes/links.tsx": js` + import { useLoaderData, Link } from "react-router"; + import redTextHref from "~/redText.css?url"; + import blueTextHref from "~/blueText.css?url"; + import guitar from "~/guitar.jpg"; + export async function loader() { + return [ + { name: "Michael Jackson", id: "mjackson" }, + { name: "Ryan Florence", id: "ryanflorence" }, + ]; + } + export function links() { + return [ + { rel: "stylesheet", href: redTextHref }, + { + rel: "stylesheet", + href: blueTextHref, + media: "(prefers-color-scheme: beef)", + }, + { page: "/gists/mjackson" }, + { + rel: "preload", + as: "image", + href: guitar, + }, + ]; + } + export default function LinksPage() { + let users = useLoaderData(); + return ( +
      +

      Links Page

      {users.map((user) => (
    • - - {user.name} {locationPending ? "..." : null} + + {user.name}
    • ))} -
    -
    - -
    - ); - } - `, - - "app/routes/gists.$username.tsx": js` - import { data, redirect, Link, useLoaderData, useParams } from "react-router"; - export async function loader({ params }) { - let { username } = params; - if (username === "mjijackson") { - return redirect("/gists/mjackson", 302); - } - if (username === "_why") { - return data(null, { status: 404 }); - } - return ${JSON.stringify(fakeGists)}; - } - export function headers() { - return { - "Cache-Control": "public, max-age=300", - }; - } - export function meta({ data, params }) { - let { username } = params; - return [ - { - title: data - ? data.length + " gists from " + username - : "User " + username + " not found", - }, - { name: "description", content: "View all of the gists from " + username }, - ]; - } - export let handle = { - breadcrumb: ({ params }) => ( - {params.username} - ), - }; - export default function UserGists() { - let { username } = useParams(); - let data = useLoaderData(); - return ( -
    - {data ? ( - <> -

    All gists from {username}

    +
    +

    + a guitar Prefetched + because it's a preload. +

    +
    + ); + } + `, + + "app/routes/responsive-image-preload.tsx": js` + import { Link } from "react-router"; + import guitar600 from "~/guitar-600.jpg"; + import guitar900 from "~/guitar-900.jpg"; + + export function links() { + return [ + { + rel: "preload", + as: "image", + imageSrcSet: guitar600 + " 600w, " + guitar900 + " 900w", + imageSizes: "100vw", + }, + ]; + } + export default function LinksPage() { + return ( +
    +

    Responsive Guitar

    +

    + a guitar{" "} + Prefetched because it's a preload. +

    +
    + ); + } + `, + + "app/routes/gists.tsx": js` + import { data, Link, Outlet, useLoaderData, useNavigation } from "react-router"; + import stylesHref from "~/gists.css?url"; + export function links() { + return [{ rel: "stylesheet", href: stylesHref }]; + } + export async function loader() { + return data({ + users: [ + { id: "ryanflorence", name: "Ryan Florence" }, + { id: "mjackson", name: "Michael Jackson" }, + ], + }, { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + } + export function headers({ loaderHeaders }) { + return { + "Cache-Control": loaderHeaders.get("Cache-Control"), + }; + } + export let handle = { + breadcrumb: () => Gists, + }; + export default function Gists() { + let locationPending = useNavigation().location; + let { users } = useLoaderData(); + return ( +
    +
    +

    Gists

    +
      + {users.map((user) => ( +
    • + + {user.name} {locationPending ? "..." : null} + +
    • + ))} +
    +
    + +
    + ); + } + `, + + "app/routes/gists.$username.tsx": js` + import { data, redirect, Link, useLoaderData, useParams } from "react-router"; + export async function loader({ params }) { + let { username } = params; + if (username === "mjijackson") { + return redirect("/gists/mjackson", 302); + } + if (username === "_why") { + return data(null, { status: 404 }); + } + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=300", + }; + } + export function meta({ data, params }) { + let { username } = params; + return [ + { + title: data + ? data.length + " gists from " + username + : "User " + username + " not found", + }, + { name: "description", content: "View all of the gists from " + username }, + ]; + } + export let handle = { + breadcrumb: ({ params }) => ( + {params.username} + ), + }; + export default function UserGists() { + let { username } = useParams(); + let data = useLoaderData(); + return ( +
    + {data ? ( + <> +

    All gists from {username}

    + + + ) : ( +

    No gists for {username}

    + )} +
    + ); + } + `, + + "app/routes/gists._index.tsx": js` + import { useLoaderData } from "react-router"; + export async function loader() { + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=60", + }; + } + export function meta() { + return [ + { title: "Public Gists" }, + { name: "description", content: "View the latest gists from the public" }, + ]; + } + export let handle = { + breadcrumb: () => Public, + }; + export default function GistsIndex() { + let data = useLoaderData(); + return ( +
    +

    Public Gists

    - - ) : ( -

    No gists for {username}

    - )} -
    - ); - } - `, - - "app/routes/gists._index.tsx": js` - import { useLoaderData } from "react-router"; - export async function loader() { - return ${JSON.stringify(fakeGists)}; - } - export function headers() { - return { - "Cache-Control": "public, max-age=60", - }; - } - export function meta() { - return [ - { title: "Public Gists" }, - { name: "description", content: "View the latest gists from the public" }, - ]; - } - export let handle = { - breadcrumb: () => Public, - }; - export default function GistsIndex() { - let data = useLoaderData(); - return ( -
    -

    Public Gists

    - -
    - ); - } - `, - - "app/routes/resources.theme-css.tsx": js` - import { redirect } from "react-router"; - export async function loader({ request }) { - return new Response(":root { --nc-tx-1: #ffffff; --nc-tx-2: #eeeeee; }", - { - headers: { - "Content-Type": "text/css; charset=UTF-8", - "x-has-custom": "yes", - }, - } - ); - } - - `, - - "app/routes/parent.tsx": js` - import { Outlet } from "react-router"; - - export function links() { - return [ - { "data-test-id": "red" }, - ]; - } - - export default function Component() { - return
    ; - } - - export function ErrorBoundary() { - return

    Error Boundary

    ; - } - `, - - "app/routes/parent.child.tsx": js` - import { Outlet } from "react-router"; - - export function loader() { - throw new Response(null, { status: 404 }); - } - - export function links() { - return [ - { "data-test-id": "blue" }, - ]; - } - - export default function Component() { - return
    ; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("adds responsive image preload links to the document", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/responsive-image-preload"); - await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); - let locator = page.locator("link[rel=preload][as=image]"); - expect(await locator.getAttribute("imagesizes")).toBe("100vw"); - }); - - test("waits for new styles to load before transitioning", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - let cssResponses = app.collectResponses((url) => - url.pathname.endsWith(".css"), - ); - - await page.click('a[href="/gists"]'); - await page.waitForSelector('[data-test-id="/gists/index"]'); - - let stylesheetResponses = cssResponses.filter((res) => { - // ignore prefetches - return res.request().resourceType() === "stylesheet"; - }); + + ); + } + `, + + "app/routes/resources.theme-css.tsx": js` + import { redirect } from "react-router"; + export async function loader({ request }) { + return new Response(":root { --nc-tx-1: #ffffff; --nc-tx-2: #eeeeee; }", + { + headers: { + "Content-Type": "text/css; charset=UTF-8", + "x-has-custom": "yes", + }, + } + ); + } - expect(stylesheetResponses.length).toEqual(1); - }); - - test("does not render errored child route links", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await page.click('a[href="/parent/child"]'); - await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); - await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); - await page.waitForSelector('[data-test-id="blue"]', { - state: "detached", - }); - }); + `, - test.describe("no js", () => { - test.use({ javaScriptEnabled: false }); + "app/routes/parent.tsx": js` + import { Outlet } from "react-router"; - test("adds links to the document", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let responses = app.collectResponses((url) => - url.pathname.endsWith(".css"), - ); + export function links() { + return [ + { "data-test-id": "red" }, + ]; + } - await app.goto("/links"); - await page.waitForSelector('[data-test-id="/links"]'); - expect(responses.length).toEqual(4); - }); + export default function Component() { + return
    ; + } - test("adds responsive image preload links to the document", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/responsive-image-preload"); - await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); - let locator = page.locator("link[rel=preload][as=image]"); - expect(await locator.getAttribute("imagesizes")).toBe("100vw"); - }); + export function ErrorBoundary() { + return

    Error Boundary

    ; + } + `, + + "app/routes/parent.child.tsx": js` + import { Outlet } from "react-router"; - test("does not render errored child route links", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); - await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); - await page.waitForSelector('[data-test-id="blue"]', { - state: "detached", + export function loader() { + throw new Response(null, { status: 404 }); + } + + export function links() { + return [ + { "data-test-id": "blue" }, + ]; + } + + export default function Component() { + return
    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector( + '[data-test-id="/responsive-image-preload"]', + ); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("waits for new styles to load before transitioning", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + let cssResponses = app.collectResponses((url) => + url.pathname.endsWith(".css"), + ); + + await page.click('a[href="/gists"]'); + await page.waitForSelector('[data-test-id="/gists/index"]'); + + let stylesheetResponses = cssResponses.filter((res) => { + // ignore prefetches + return res.request().resourceType() === "stylesheet"; + }); + + expect(stylesheetResponses.length).toEqual(1); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.click('a[href="/parent/child"]'); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { + state: "attached", + }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + + test.describe("no js", () => { + test.use({ javaScriptEnabled: false }); + + test("adds links to the document", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let responses = app.collectResponses((url) => + url.pathname.endsWith(".css"), + ); + + await app.goto("/links"); + await page.waitForSelector('[data-test-id="/links"]'); + expect(responses.length).toEqual(4); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector( + '[data-test-id="/responsive-image-preload"]', + ); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { + state: "attached", + }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + }); + + test.describe("script imports", () => { + test.skip(templateName.includes("rsc"), "Not relevant for RSC"); + + // Disable JS for this test since we don't want it to hydrate and remove + // the initial