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 (
-
-
-
-
- );
- }
- `,
-
- "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}
-
-
- ))}
-
-
-
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
-
-
{" "}
- 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 (
-
+ );
+ }
+ `,
+
+ "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