diff --git a/.changeset/react-router-8-default-template.md b/.changeset/react-router-8-default-template.md new file mode 100644 index 00000000..03e007b2 --- /dev/null +++ b/.changeset/react-router-8-default-template.md @@ -0,0 +1,10 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Add React Router 8 compatibility while preserving React Router 7 behavior. +The plugin now supports stable React Router 8 config fields, resolves +prerender data requests for the installed React Router major version, supports +React Router RSC mode, analyzes transformed MDX route modules for manifest +generation, and includes React Router 8/RSC examples plus framework integration +coverage. diff --git a/README.md b/README.md index 8f51f7b8..42b4d21b 100644 --- a/README.md +++ b/README.md @@ -780,6 +780,7 @@ The repository includes several examples demonstrating different use cases: | [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | | [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | | [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | +| [react-router-8](./examples/react-router-8) | React Router 8 framework-mode SSR | 3020 | `pnpm dev` | | [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | | [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | | [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | diff --git a/examples/react-router-8/.gitignore b/examples/react-router-8/.gitignore new file mode 100644 index 00000000..c08251ce --- /dev/null +++ b/examples/react-router-8/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +.env +.react-router diff --git a/examples/react-router-8/README.md b/examples/react-router-8/README.md new file mode 100644 index 00000000..d6107db5 --- /dev/null +++ b/examples/react-router-8/README.md @@ -0,0 +1,5 @@ +# React Router 8 Default Template + +This example starts as a direct copy of React Router's `integration/helpers/vite-8-template` and swaps the Vite config/scripts for `rsbuild-plugin-react-router`. + +Run `pnpm --filter react-router-8-default-template test:e2e` to exercise development and production browser flows. diff --git a/examples/react-router-8/app/root.tsx b/examples/react-router-8/app/root.tsx new file mode 100644 index 00000000..b36392b4 --- /dev/null +++ b/examples/react-router-8/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + +
+ + + +{message}
+{loaderData.message}
+{loaderData.element}
++ This route renders through React Router RSC Framework Mode and mounts a + small client island inside the server-first route. +
++ This route uses the conventional default route component export so the + example exercises navigation between server-first and client-first + routes. +
+{actionData ? String(actionData.aborted) : "empty"}
+{String(data.aborted)}
+ ++ Is the form dirty?{" "} + {value !== "" ? ( + Yes + ) : ( + No + )} +
+ + > + ); + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/a"); + await page.waitForSelector("#a"); + await app.clickLink("/b"); + await page.waitForSelector("#b"); + await page.getByLabel("Enter some important data:").fill("Hello Remix!"); + + // Going back should: + // - block + // - immediately call blocker.proceed() once we enter the blocked state + // - and land back one history entry (/a) + await page.goBack(); + await page.waitForSelector("#a"); + expect(await app.getHtml()).toContain("A"); +}); diff --git a/tests/react-router-framework/integration/browser-entry-test.ts b/tests/react-router-framework/integration/browser-entry-test.ts new file mode 100644 index 00000000..ec1731f5 --- /dev/null +++ b/tests/react-router-framework/integration/browser-entry-test.ts @@ -0,0 +1,322 @@ +import { test, expect } from "@playwright/test"; + +import { + createFixture, + js, + createAppFixture, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test( + "expect to be able to browse backward out of a remix app, then forward " + + "twice in history and have pages render correctly", + async ({ page, browserName }) => { + test.skip( + browserName === "firefox", + "FireFox doesn't support browsing to an empty page (aka about:blank)", + ); + + let fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +{fetcher.data} : null}
+ >
+ );
+ }
+ `,
+ },
+ });
+
+ let logs: string[] = [];
+ page.on("console", (msg) => logs.push(msg.text()));
+
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/", true);
+ await page.click('a[href="/page"]');
+ await page.waitForSelector("[data-page]");
+
+ expect(await app.getHtml()).toContain("hello world");
+ expect(logs).toEqual([
+ 'start navigate [["currentUrl","/"],["to","/page"]]',
+ "start loader root /page",
+ "start loader routes/page /page",
+ "end loader root /page",
+ "end loader routes/page /page",
+ 'end navigate [["currentUrl","/"],["to","/page"]]',
+ ]);
+ logs.splice(0);
+
+ await page.click("[data-fetch]");
+ await page.waitForSelector("[data-fetcher-data]");
+ await expect(page.locator("[data-fetcher-data]")).toContainText("OK");
+ expect(logs).toEqual([
+ 'start fetch [["body",{"key":"value"}],["currentUrl","/page"],["fetcherKey","a"],["formData",null],["formEncType","application/x-www-form-urlencoded"],["formMethod","post"],["href","/page"]]',
+ "start action routes/page /page",
+ "end action routes/page /page",
+ "start loader root /page",
+ "start loader routes/page /page",
+ "end loader root /page",
+ "end loader routes/page /page",
+ 'end fetch [["body",{"key":"value"}],["currentUrl","/page"],["fetcherKey","a"],["formData",null],["formEncType","application/x-www-form-urlencoded"],["formMethod","post"],["href","/page"]]',
+ ]);
+
+ appFixture.close();
+});
diff --git a/tests/react-router-framework/integration/bug-report-test.ts b/tests/react-router-framework/integration/bug-report-test.ts
new file mode 100644
index 00000000..8be63c7f
--- /dev/null
+++ b/tests/react-router-framework/integration/bug-report-test.ts
@@ -0,0 +1,127 @@
+import { test, expect } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+////////////////////////////////////////////////////////////////////////////////
+// 👋 Hola! I'm here to help you write a great bug report pull request.
+//
+// You don't need to fix the bug, this is just to report one.
+//
+// The pull request you are submitting is supposed to fail when created, to let
+// the team see the erroneous behavior, and understand what's going wrong.
+//
+// If you happen to have a fix as well, it will have to be applied in a subsequent
+// commit to this pull request, and your now-succeeding test will have to be moved
+// to the appropriate file.
+//
+// First, make sure to install dependencies and build React Router. From the root of
+// the project, run this:
+//
+// ```
+// pnpm install && pnpm build
+// ```
+//
+// If you have never installed playwright on your system before, you may also need
+// to install a browser engine:
+//
+// ```
+// pnpm exec playwright install chromium
+// ```
+//
+// Now try running this test:
+//
+// ```
+// pnpm test:integration bug-report --project chromium
+// ```
+//
+// You can add `--watch` to the end to have it re-run on file changes:
+//
+// ```
+// pnpm test:integration bug-report --project chromium --watch
+// ```
+////////////////////////////////////////////////////////////////////////////////
+
+test.beforeEach(async ({ context }) => {
+ await context.route(/\.data$/, async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ route.continue();
+ });
+});
+
+test.beforeAll(async () => {
+ fixture = await createFixture({
+ ////////////////////////////////////////////////////////////////////////////
+ // 💿 Next, add files to this object, just like files in a real app,
+ // `createFixture` will make an app and run your tests against it.
+ ////////////////////////////////////////////////////////////////////////////
+ files: {
+ "app/routes/_index.tsx": js`
+ import { useLoaderData, Link } from "react-router";
+
+ export function loader() {
+ return "pizza";
+ }
+
+ export default function Index() {
+ let data = useLoaderData();
+ return (
+ {JSON.stringify(matches)}
+ ${OWN_BOUNDARY_TEXT}
+ } + export default function Index() { + return ( + + ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export function action() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return ( + + ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + import { useRouteError } from "react-router"; + export function loader() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +{error.status}
+ >
+ );
+ }
+ export default function Index() {
+ return
+ }
+ `,
+
+ [`app/routes${HAS_BOUNDARY_LOADER_FILE}.child.jsx`]: js`
+ export function loader() {
+ throw new Response("", { status: 404 })
+ }
+ export default function Index() {
+ return
+ }
+ `,
+
+ [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js`
+ export function loader() {
+ throw new Response("", { status: 401 })
+ }
+ export default function Index() {
+ return
+ }
+ `,
+
+ "app/routes/action.tsx": js`
+ import { Outlet, useLoaderData } from "react-router";
+
+ export function loader() {
+ return "PARENT";
+ }
+
+ export default function () {
+ return (
+ {useLoaderData()}
+{useLoaderData()}
+ + > + ) + } + + export function ErrorBoundary() { + let error = useRouteError() + return{error.status} {error.data}
; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + originalConsoleError = console.error; + console.error = () => {}; + originalConsoleWarn = console.warn; + console.warn = () => {}; + }); + + test.afterAll(() => { + appFixture.close(); + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`${expected}`);
+
+ console.error = oldConsoleError;
+ });
+
+ test("non-matching urls on client transitions", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickLink(NOT_FOUND_HREF, { wait: false });
+ await page.waitForSelector("#root-boundary");
+
+ // Root loader data sticks around from previous load
+ let expected = JSON.stringify([
+ {
+ id: "root",
+ pathname: "",
+ params: {},
+ loaderData: { data: "ROOT LOADER" },
+ },
+ ]);
+ expect(await app.getHtml("#matches")).toContain(expected);
+ });
+
+ test("own boundary, action, document request", async () => {
+ let params = new URLSearchParams();
+ let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params);
+ expect(res.status).toBe(401);
+ expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT);
+ });
+
+ test("own boundary, action, client transition from other route", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickSubmitButton(HAS_BOUNDARY_ACTION);
+ await page.waitForSelector("#action-boundary");
+ });
+
+ test("own boundary, action, client transition from itself", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto(HAS_BOUNDARY_ACTION);
+ await app.clickSubmitButton(HAS_BOUNDARY_ACTION);
+ await page.waitForSelector("#action-boundary");
+ });
+
+ test("bubbles to parent in action document requests", async () => {
+ let params = new URLSearchParams();
+ let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params);
+ expect(res.status).toBe(401);
+ expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
+ });
+
+ test("bubbles to parent in action script transitions from other routes", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickSubmitButton(NO_BOUNDARY_ACTION);
+ await page.waitForSelector("#root-boundary");
+ });
+
+ test("bubbles to parent in action script transitions from self", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto(NO_BOUNDARY_ACTION);
+ await app.clickSubmitButton(NO_BOUNDARY_ACTION);
+ await page.waitForSelector("#root-boundary");
+ });
+
+ test("own boundary, loader, document request", async () => {
+ let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER);
+ expect(res.status).toBe(401);
+ expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT);
+ });
+
+ test("own boundary, loader, client transition", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickLink(HAS_BOUNDARY_LOADER);
+ await page.waitForSelector("#boundary-loader");
+ });
+
+ test("bubbles to parent in loader document requests", async () => {
+ let res = await fixture.requestDocument(NO_BOUNDARY_LOADER);
+ expect(res.status).toBe(401);
+ expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT);
+ });
+
+ test("bubbles to parent in loader transitions from other routes", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await app.clickLink(NO_BOUNDARY_LOADER);
+ await page.waitForSelector("#root-boundary");
+ });
+
+ test("uses correct catch boundary on server action errors", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto(`/action/child-catch`);
+ expect(await app.getHtml("#parent-data")).toMatch("PARENT");
+ expect(await app.getHtml("#child-data")).toMatch("CHILD");
+ await page.click("button[type=submit]");
+ await page.waitForSelector("#child-catch");
+ // Preserves parent loader data
+ expect(await app.getHtml("#parent-data")).toMatch("PARENT");
+ expect(await app.getHtml("#child-catch")).toMatch("400");
+ expect(await app.getHtml("#child-catch")).toMatch("Caught!");
+ });
+
+ test("prefers parent catch when child loader also bubbles, document request", async () => {
+ let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`);
+ expect(res.status).toBe(401);
+ let text = await res.text();
+ expect(text).toMatch(OWN_BOUNDARY_TEXT);
+ expect(text).toMatch('401'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); +}); diff --git a/tests/react-router-framework/integration/cli-test.ts b/tests/react-router-framework/integration/cli-test.ts new file mode 100644 index 00000000..70e73554 --- /dev/null +++ b/tests/react-router-framework/integration/cli-test.ts @@ -0,0 +1,201 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import * as path from "node:path"; + +import { expect, test } from "@playwright/test"; +import dedent from "dedent"; +import semver from "semver"; + +import { createProject } from "./helpers/vite"; + +const nodeBin = process.argv[0]; +const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js"; + +const run = (command: string[], options: Parameters
Parent Fallback
+ } + ` + : "" + } + ${parentAdditions || ""} + export default function Component() { + let data = useLoaderData(); + return ( + <> +{data.message}
+Child Fallback
+ } + ` + : "" + } + ${childAdditions || ""} + export default function Component() { + let data = useLoaderData(); + let actionData = useActionData(); + return ( + <> +{data.message}
+ + > + ); + } + `, + }; + } + + test.describe(`template: ${templateName}`, () => { + for (const splitRouteModules of [true, false]) { + test.describe(`splitRouteModules: ${splitRouteModules}`, () => { + test.skip( + templateName.includes("rsc") && splitRouteModules, + "RSC Framework Mode doesn't support splitRouteModules", + ); + + test.skip( + ({ browserName }) => + Boolean(process.env.CI) && + splitRouteModules && + (browserName === "webkit" || process.platform === "win32"), + "Webkit/Windows tests only run on a single worker in CI and splitRouteModules is not OS/browser-specific", + ); + + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture( + { + templateName, + files: { + "react-router.config.ts": reactRouterConfig({ + splitRouteModules, + }), + "app/root.tsx": js` + import { Form, Outlet, Scripts } from "react-router" + + export const middleware = [ + async ({ request }, next) => { + let response = await next(); + + if ( + request.method === "GET" && + response instanceof Response && + response.status === 200 && + request.headers.get("sec-purpose") === "prefetch" && + !response.headers.has("Cache-Control") + ) { + let cachedResponse = new Response(response.body, response); + cachedResponse.headers.set("Cache-Control", "max-age=5"); + return cachedResponse; + } + return response; + } + ]; + + export default function Root() { + return ( + + + +Parent Fallback
+ } + `, + childAdditions: js` + export function clientLoader() { + return { message: "Child Client Loader" }; + } + clientLoader.hydrate=true + `, + }, + ), + + ...getFiles( + "client-loader-critical.handles-deferred-data-through-client-loaders", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.handles-deferred-data-through-client-loaders.parent.child.tsx": js` + import * as React from 'react'; + import { Await, useLoaderData } from "react-router" + export function loader() { + return { + message: 'Child Server Loader', + lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), + }; + } + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { + ...data, + message: data.message + " (mutated by client)", + }; + } + clientLoader.hydrate = true; + export function HydrateFallback() { + returnChild Fallback
+ } + export default function Component() { + let data = useLoaderData(); + return ( + <> +{data.message}
+{value}
} +SHOULD NOT SEE ME
+ } + export default function Component() { + let data = useLoaderData(); + return{data.message}
; + } + `, + + ...getFiles( + "client-loader-critical.client-loader-hydrate-is-automatically-implied-when-no-server-loader-exists-with-hydrate-fallback", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.client-loader-hydrate-is-automatically-implied-when-no-server-loader-exists-with-hydrate-fallback.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from "react-router"; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export function HydrateFallback() { + returnChild Fallback
+ } + export default function Component() { + let data = useLoaderData(); + return{data.message}
; + } + `, + + ...getFiles( + "client-loader-critical.client-loader-hydrate-is-automatically-implied-when-no-server-loader-exists-without-hydrate-fallback", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.client-loader-hydrate-is-automatically-implied-when-no-server-loader-exists-without-hydrate-fallback.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from "react-router"; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export default function Component() { + let data = useLoaderData(); + return{data.message}
; + } + `, + + ...getFiles( + "client-loader-critical.throws-a-400-if-you-call-serverloader-without-a-server-loader", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.throws-a-400-if-you-call-serverloader-without-a-server-loader.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from "react-router"; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + returnChild
; + } + export function HydrateFallback() { + returnLoading...
; + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + + ...getFiles( + "client-loader-critical.initial-hydration-data-check-functions-properly", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.initial-hydration-data-check-functions-properly.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRevalidator } from "react-router"; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return { message: "Child Server Loader Data (1)" }; + } + return { message: "Child Server Loader Data (2+)" }; + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +{data.message}
+ + > + ); + } + export function HydrateFallback() { + returnLoading...
+ } + `, + + ...getFiles( + "client-loader-critical.initial-hydration-data-check-functions-properly-even-if-serverloader-isnt-called-on-hydration", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.initial-hydration-data-check-functions-properly-even-if-serverloader-isnt-called-on-hydration.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRevalidator } from "react-router"; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return { message: "Child Server Loader Data (1)" }; + } + return { message: "Child Server Loader Data (2+)" }; + } + let isFirstClientCall = true; + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + if (isFirstClientCall) { + isFirstClientCall = false; + // First time through - don't even call serverLoader + return { + message: "Child Client Loader Data", + }; + } + // Only call the serverLoader on subsequent calls and this + // should *not* return us the initialData any longer + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +{data.message}
+ + > + ); + } + export function HydrateFallback() { + returnLoading...
+ } + `, + + ...getFiles( + "client-loader-critical.server-loader-errors-are-re-thrown-from-serverloader", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.server-loader-errors-are-re-thrown-from-serverloader.parent.child.tsx": js` + import { useRouteError } from "react-router"; + + export function loader() { + throw new Error("Broken!") + } + + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + clientLoader.hydrate = true; + + export default function Index() { + return{error.message}
; + } + `, + + ...getFiles( + "client-loader-critical.bubbled-server-loader-errors-are-persisted-for-hydrating-routes", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-critical.bubbled-server-loader-errors-are-persisted-for-hydrating-routes.parent.tsx": js` + import { Outlet, useLoaderData, useRouteLoaderData, useRouteError } from 'react-router' + export function loader() { + return { message: 'Parent Server Loader' }; + } + export async function clientLoader({ serverLoader }) { + console.log('running parent client loader') + // Need a small delay to ensure we capture the server-rendered + // fallbacks for assertions + await new Promise(r => setTimeout(r, 100)); + let data = await serverLoader(); + return { message: data.message + " (mutated by client)" }; + } + clientLoader.hydrate = true; + export default function Component() { + let data = useLoaderData(); + return ( + <> +{data.message}
+{data?.message}
+{error?.message}
+ > + ); + } + `, + "app/routes/client-loader-critical.bubbled-server-loader-errors-are-persisted-for-hydrating-routes.parent.child.tsx": js` + import { useLoaderData } from 'react-router' + export function loader() { + throw new Error('Child Server Error'); + } + export function clientLoader() { + console.log('running child client loader') + return "Should not see me"; + } + clientLoader.hydrate = true; + export default function Component() { + let data = useLoaderData() + return ( + <> +Should not see me
+{data}
; + > + ); + } + `, + + ...getFiles( + "client-loader-lazy.no-client-loaders-or-fallbacks", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + + ...getFiles("client-loader-lazy.parent-client-loader", { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + + ...getFiles("client-loader-lazy.child-client-loader", { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + + ...getFiles( + "client-loader-lazy.parent-client-loader-child-client-loader", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }, + ), + + ...getFiles( + "client-loader-lazy.throws-a-400-if-you-call-serverloader-without-a-server-loader", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-loader-lazy.throws-a-400-if-you-call-serverloader-without-a-server-loader.parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from "react-router"; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + returnChild
; + } + export function HydrateFallback() { + returnLoading...
; + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + + ...getFiles( + "client-loader-lazy.does-not-prefetch-server-loader-if-a-client-loader-is-present", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + + ...getFiles("client-action-critical.child-client-action", { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + + ...getFiles( + "client-action-critical.child-client-action-parent-child-loader", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-critical.child-client-action-child-client-loader", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-critical.child-client-action-parent-child-loader-child-client-loader", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-critical.throws-a-400-if-you-call-serveraction-without-a-server-action", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-action-critical.throws-a-400-if-you-call-serveraction-without-a-server-action.parent.child.tsx": js` + import * as React from 'react'; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + + ...getFiles("client-action-lazy.child-client-action", { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + + ...getFiles( + "client-action-lazy.child-client-action-parent-child-loader", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-lazy.child-client-action-child-client-loader", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-lazy.child-client-action-parent-child-loader-child-client-loader", + { + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }, + ), + + ...getFiles( + "client-action-lazy.throws-a-400-if-you-call-serveraction-without-a-server-action", + { + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }, + ), + "app/routes/client-action-lazy.throws-a-400-if-you-call-serveraction-without-a-server-action.parent.child.tsx": js` + import * as React from 'react'; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + + "app/routes/client-loader-critical.hydrating-clientloader-redirects-trigger-new-data-requests-to-the-server.tsx": js` + import { Outlet } from 'react-router' + + let count = 1; + export function loader() { + return count++; + } + export default function Component({ loaderData }) { + return ( + <> +{loaderData}
+{loaderData}
+Should not see me
; + } + `, + "app/routes/client-loader-critical.hydrating-clientloader-redirects-trigger-new-data-requests-to-the-server.parent.b.tsx": js` + export default function Component({ loaderData }) { + returnHi!
; + } + `, + + "app/routes/client-loader-critical.aborted-hydration-fetches-fresh-data.tsx": js` + import { Link } from "react-router"; + + export function loader({ request }) { + return { query: new URL(request.url).searchParams.get("q") || "empty" }; + } + + export async function clientLoader({ serverLoader, request }) { + let q = new URL(request.url).searchParams.get("q") || "empty"; + + // Delay the initial invocation + if (q === "initial") { + if (!window.__hydrationBlock) { + let { promise, resolve } = Promise.withResolvers(); + window.__resolveHydrationBlock = resolve + window.__hydrationBlock = promise; + await window.__hydrationBlock; + } + } + + let serverData = await serverLoader(); + return { + ...serverData, + clientLoaderRan: true, + clientLoaderQuery: q, + }; + } + + clientLoader.hydrate = true; + + export default function Component({ loaderData }) { + return ( +{loaderData.query}
+{String(loaderData.clientLoaderQuery ?? "none")}
+ + Update query + +Loader count: {loaderData.loaderCount}
+Client loader count: {loaderData.clientLoaderCount}
+Server custom export count: {loaderData.serverCustomExportCount}
+Client custom export count: {loaderData.clientCustomExportCount}
+Server component count: {loaderData.serverComponentCount}
+Client component count: {loaderData.clientComponentCount}
+Go to Route B
+ > + ); + }; + })(); + + export default RouteA; + `, + "app/routes/client-first.b.tsx": ` + import { Link } from "react-router"; + + import { customExport } from "./client-first.a"; + + export default function RouteB() { + return customExport && ( + <> +This route imports the route module from Route A, so could potentially cause code duplication.
+Go to Route A
+ > + ); + } + `, + + ...(templateName.includes("rsc") + ? { + "app/routes/rsc-server-first.a/route.tsx": ` + import { Link } from "react-router"; + import { ModuleCounts, clientLoader } from "./client"; + + export const customExport = (() => { + globalThis.rsc_custom_export_count = (globalThis.rsc_custom_export_count || 0) + 1; + return () => true; + })(); + + export const loader = (() => { + globalThis.rsc_loader_count = (globalThis.rsc_loader_count || 0) + 1; + return () => ({ + customExportCount: globalThis.rsc_custom_export_count, + loaderCount: globalThis.rsc_loader_count, + componentCount: globalThis.rsc_component_count, + }); + })(); + + export { clientLoader }; + + export const ServerComponent = (() => { + globalThis.rsc_component_count = (globalThis.rsc_component_count || 0) + 1; + return () => { + return ( + <> +Go to RSC Route B
+ > + ); + }; + })(); + `, + "app/routes/rsc-server-first.a/client.tsx": ` + "use client"; + + import { useLoaderData } from "react-router"; + + export const clientLoader = (() => { + globalThis.rsc_client_loader_count = (globalThis.rsc_client_loader_count || 0) + 1; + return async ({ serverLoader }) => { + const loaderData = await serverLoader(); + return { + loaderCount: loaderData.loaderCount, + clientLoaderCount: globalThis.rsc_client_loader_count, + serverCustomExportCount: loaderData.customExportCount, + clientCustomExportCount: globalThis.rsc_custom_export_count, + serverComponentCount: loaderData.componentCount, + }; + }; + })(); + clientLoader.hydrate = true; + + export function ModuleCounts() { + const loaderData = useLoaderData(); + return ( + <> +Loader count: {loaderData.loaderCount}
+Client loader count: {loaderData.clientLoaderCount}
+Server custom export count: {loaderData.serverCustomExportCount}
+Client custom export count: {loaderData.clientCustomExportCount}
+Server component count: {loaderData.serverComponentCount}
+ > + ); + } + `, + "app/routes/rsc-server-first.b.tsx": ` + import { Link } from "react-router"; + + import { customExport } from "./rsc-server-first.a/route"; + + // Ensure custom export is used in the client build in this route + export const handle = customExport; + + export function ServerComponent() { + return customExport && ( + <> +This route imports the route module from RSC Route A, so could potentially cause code duplication.
+Go to RSC Route A
+ > + ); + } + `, + } + : {}), + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + let logs: string[] = []; + + test.beforeEach(({ page }) => { + page.on("console", (msg) => { + logs.push(msg.text()); + }); + }); + + test.afterEach(() => { + expect(logs).toHaveLength(0); + }); + + test("Client-first routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await app.goto(`/client-first/b`, true); + expect(pageErrors).toEqual([]); + + await app.clickLink("/client-first/a"); + await page.waitForSelector("[data-loader-count]"); + expect(await page.locator("[data-loader-count]").textContent()).toBe( + "1", + ); + expect( + await page.locator("[data-client-loader-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-custom-export-count]").textContent(), + ).toBe( + templateName.includes("rsc") + ? // In RSC, custom exports are present in both the react-server and react-client + // environments (so they're available to be imported by both), + // which means the Node server actually gets 2 copies + "2" + : "1", + ); + expect( + await page.locator("[data-client-custom-export-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-component-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-client-component-count]").textContent(), + ).toBe("1"); + expect(pageErrors).toEqual([]); + }); + + test("Server-first routes", async ({ page }) => { + test.skip( + !templateName.includes("rsc"), + "Server-first routes are an RSC-only feature", + ); + + let app = new PlaywrightFixture(appFixture, page); + + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await app.goto(`/rsc-server-first/b`, true); + expect(pageErrors).toEqual([]); + + await app.clickLink("/rsc-server-first/a"); + await page.waitForSelector("[data-loader-count]"); + expect(await page.locator("[data-loader-count]").textContent()).toBe( + "1", + ); + expect( + await page.locator("[data-client-loader-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-custom-export-count]").textContent(), + ).toBe( + // In RSC, custom exports are present in both the react-server and react-client + // environments (so they're available to be imported by both), + // which means the Node server actually gets 2 copies + "2", + ); + expect( + await page.locator("[data-client-custom-export-count]").textContent(), + ).toBe("1"); + expect( + await page.locator("[data-server-component-count]").textContent(), + ).toBe("1"); + expect(pageErrors).toEqual([]); + }); + }); + } +}); diff --git a/tests/react-router-framework/integration/defer-loader-test.ts b/tests/react-router-framework/integration/defer-loader-test.ts new file mode 100644 index 00000000..9fc9beb6 --- /dev/null +++ b/tests/react-router-framework/integration/defer-loader-test.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "react-router"; + export default function Index() { + return ( +${val}
`; +} + +const deferredHTMLStartString = " { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +{count}
+interactive
+{id}
+YOOOOOOOOOO {i}
)} + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + id: "${INDEX_ID}", + }; + } + + export default function Index() { + let { id } = useLoaderData(); + return ( +{id}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{id}
+{JSON.stringify(value)}
+ { + let { status, criticalHTML, deferredHTML } = await getHtmlSections( + fixture, + "/deferred-noscript-resolved", + ); + + expect(status).toBe(200); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + expect(deferredHTML).toContain(FALLBACK_ID); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + }); + + test("slow promises render in subsequent payload", async () => { + let { status, criticalHTML, deferredHTML } = await getHtmlSections( + fixture, + "/deferred-noscript-unresolved", + ); + + expect(status).toBe(200); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + expect(deferredHTML).toContain(`
${OWN_BOUNDARY_TEXT}
+ } + export default function () { + return ( + + ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export function action() { + throw new Error("Kaboom!") + } + export default function () { + return ( + + ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return${OWN_BOUNDARY_TEXT}
+ } + export default function() { + let fetcher = useFetcher(); + + return ( +{useLoaderData()}
+{useLoaderData()}
+ + > + ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return{error.message}
; + } + `, + }, + }, + ServerMode.Development, + ); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + test("invalid request methods", async () => { + let res = await fixture.requestDocument("/", { method: "OPTIONS" }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with no boundary", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with no boundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_RENDER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with boundary", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_RENDER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("uses correct error boundary on server action errors in nested routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-error`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-error"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-error")).toMatch("Broken!"); + }); + + test("renders own boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#fetcher-boundary"); + }); + + test("renders root boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-no-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders root boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, { + method: "post", + }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("renders root boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders own boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_NO_LOADER_OR_ACTION, { + method: "post", + }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("renders own boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#boundary-no-loader-or-action"); + }); + + test.describe("if no error boundary exists in the app", () => { + let NO_ROOT_BOUNDARY_LOADER = "/loader-bad" as const; + let NO_ROOT_BOUNDARY_ACTION = "/action-bad" as const; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + +{error.message}
+{oh.no.what.have.i.done}
+{error.message}
+{useLoaderData()}
+{error.status + ' ' + error.data}
: +{error.message}
; + } + `, + + "app/routes/parent.child-with-boundary.tsx": js` + import { + isRouteErrorResponse, + useLoaderData, + useLocation, + useRouteError, + } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return{data}
; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +{error.status + ' ' + error.data}
: +{error.message}
; + } + `, + + "app/routes/parent.child-without-boundary.tsx": js` + import { useLoaderData, useLocation } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return{data}
; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + // Cause a .data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { + route.fulfill({ status: 500, body: "CDN Error!" }); + }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "500 CDN Error!", + ); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#child-error-response", + "418 Loader Response", + ); + }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response", + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } +}); + +// Shorthand util to wait for an element to appear before asserting it +async function waitForAndAssert( + page: Page, + app: PlaywrightFixture, + selector: string, + match: string, +) { + await page.waitForSelector(selector); + expect(await app.getHtml(selector)).toMatch(match); +} diff --git a/tests/react-router-framework/integration/error-data-request-test.ts b/tests/react-router-framework/integration/error-data-request-test.ts new file mode 100644 index 00000000..7dddf9ad --- /dev/null +++ b/tests/react-router-framework/integration/error-data-request-test.ts @@ -0,0 +1,168 @@ +import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "react-router"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + let errorLogs: any[]; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = (v) => errorLogs.push(v); + + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + +{JSON.stringify(data)}
+ > + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +{"MESSAGE:" + error.message}
+{"NAME:" + error.name}
+ {error.stack ?{"STACK:" + error.stack}
: null} + > + ); + } + `, + + "app/routes/defer.tsx": js` + import * as React from 'react'; + import { Await, useAsyncError, useLoaderData, useRouteError } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + return { + lazy: Promise.reject(new Error("REJECTED")), + }; + } + return { + lazy: Promise.resolve("RESOLVED"), + }; + } + + export default function Component() { + let data = useLoaderData(); + + return ( + <> +{val}
} +{error.message}
+ > + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +{"MESSAGE:" + error.message}
+ {error.stack ?{"STACK:" + error.stack}
: null} + > + ); + } + `, + + "app/routes/resource.tsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + return "RESOURCE LOADER" + } + `, +}; + +test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Production, + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch('\\"SanitizedError\\"'); + expect(html).toMatch('\\"Error\\",\\"Unexpected Server Error\\"'); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_.data"); + expect(data).toEqual({ + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_.data?loader"); + expect(data).toEqual({ + "routes/_index": { + error: new Error("Unexpected Server Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"', + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("does not support hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch("
NAME:Error"); + + // Hydration + let appFixture = await createAppFixture(fixture); + try { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("
MESSAGE:Unexpected Server Error"); + expect(html).toMatch("
NAME:Error"); + } finally { + appFixture.close(); + } + }); + }); + + test.describe("serverMode=development", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Development, + ); + }); + let ogEnv = process.env.NODE_ENV; + test.beforeEach(() => { + ogEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + }); + test.afterEach(() => { + process.env.NODE_ENV = ogEnv; + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("
MESSAGE:Loader Error"); + expect(html).toMatch("
STACK:Error: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("
MESSAGE:Render Error"); + expect(html).toMatch("
STACK:Error: Render Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/"stack":/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("
REJECTED
"); + expect(html).toMatch("Error: REJECTED\\\\n at "); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_.data"); + expect(data).toEqual({ + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("does not sanitize loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_.data?loader"); + expect(data).toEqual({ + "routes/_index": { + error: new Error("Loader Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("REJECTED"); + expect((e as Error).stack).not.toBeUndefined(); + } + + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error\n\nError: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"', + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("
NAME:ReferenceError"); + expect(html).toMatch( + "
STACK:ReferenceError: thisisnotathing is not defined", + ); + + // Hydration + let appFixture = await createAppFixture(fixture, ServerMode.Development); + try { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("
MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("
NAME:ReferenceError");
+ expect(html).toMatch(
+ "STACK:ReferenceError: thisisnotathing is not defined",
+ );
+ } finally {
+ appFixture.close();
+ }
+ });
+ });
+
+ test.describe("serverMode=production (user-provided handleError)", () => {
+ test.beforeAll(async () => {
+ fixture = await createFixture(
+ {
+ files: {
+ "app/entry.server.tsx": js`
+ import { PassThrough } from "node:stream";
+
+ import { createReadableStreamFromReadable } from "@react-router/node";
+ import { ServerRouter, isRouteErrorResponse } from "react-router";
+ import { renderToPipeableStream } from "react-dom/server";
+
+ export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ ) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+
{fetcher.data}
} +{data}
+ + {!!fetcher.data &&{fetcher.data}
} + > + ); + } + `, + + "app/routes/layout-action.$param.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => params.param; + + export let action = ({ params }) => "param action data"; + + export default function ActionLayoutChild() { + let data = useLoaderData(); + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }) + }; + + return ( + <> +{data}
+ + {!!fetcher.data &&{fetcher.data}
} + > + ); + } + `, + + "app/routes/layout-loader.tsx": js` + import { Outlet, useFetcher, useFormAction } from "react-router"; + + export let loader = () => "layout loader data"; + + export default function LoaderLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( +{fetcher.data}
} +{fetcher.data}
} + > + ); + } + `, + + "app/routes/layout-loader.$param.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => params.param; + + export default function ActionLayoutChild() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( + <> + + {!!fetcher.data &&{fetcher.data}
} + > + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("fetcher calls layout route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#layout-fetcher"); + await expect(page.locator("#layout-fetcher-data")).toHaveText( + "layout action data", + ); + await expect(page.locator("#child-data")).toHaveText("index data"); +}); + +test("fetcher calls layout route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#layout-fetcher"); + await expect(page.locator("#layout-fetcher-data")).toHaveText( + "layout loader data", + ); +}); + +test("fetcher calls index route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#index-fetcher"); + await expect(page.locator("#index-fetcher-data")).toHaveText( + "index action data", + ); + await expect(page.locator("#child-data")).toHaveText("index data"); +}); + +test("fetcher calls index route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#index-fetcher"); + await expect(page.locator("#index-fetcher-data")).toHaveText("index data"); +}); + +test("fetcher calls layout route action when at parameterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#layout-fetcher"); + await expect(page.locator("#layout-fetcher-data")).toHaveText( + "layout action data", + ); + await expect(page.locator("#child-data")).toHaveText("foo"); +}); + +test("fetcher calls layout route loader when at parameterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#layout-fetcher"); + await expect(page.locator("#layout-fetcher-data")).toHaveText( + "layout loader data", + ); +}); + +test("fetcher calls parameterized route action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#param-fetcher"); + await expect(page.locator("#param-fetcher-data")).toHaveText( + "param action data", + ); + await expect(page.locator("#child-data")).toHaveText("foo"); +}); + +test("fetcher calls parameterized route loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#param-fetcher"); + await expect(page.locator("#param-fetcher-data")).toHaveText("foo"); +}); diff --git a/tests/react-router-framework/integration/fetcher-test.ts b/tests/react-router-framework/integration/fetcher-test.ts new file mode 100644 index 00000000..aa2368b1 --- /dev/null +++ b/tests/react-router-framework/integration/fetcher-test.ts @@ -0,0 +1,593 @@ +import { expect, test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("useFetcher", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let CHEESESTEAK = "CHEESESTEAK"; + let LUNCH = "LUNCH"; + let PARENT_LAYOUT_LOADER = "parent layout loader"; + let PARENT_LAYOUT_ACTION = "parent layout action"; + let PARENT_INDEX_LOADER = "parent index loader"; + let PARENT_INDEX_ACTION = "parent index action"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/resource-route-action-only.ts": js` + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "react-router"; + + export default function FetcherActionOnlyCall() { + let fetcher = useFetcher(); + + let executeFetcher = () => { + fetcher.submit(new URLSearchParams(), { + method: 'post', + action: '/resource-route-action-only', + }); + }; + + return ( + <> + + {fetcher.data &&{fetcher.data}}
+ >
+ );
+ }
+ `,
+
+ "app/routes/resource-route.tsx": js`
+ export function loader() {
+ return new Response("${LUNCH}");
+ }
+ export function action() {
+ return new Response("${CHEESESTEAK}");
+ }
+ `,
+
+ "app/routes/_index.tsx": js`
+ import { useFetcher } from "react-router";
+ export default function Index() {
+ let fetcher = useFetcher();
+ return (
+ <>
+ {fetcher.data}
+ >
+ );
+ }
+ `,
+
+ "app/routes/parent.tsx": js`
+ import { Outlet } from "react-router";
+
+ export function action() {
+ return new Response("${PARENT_LAYOUT_ACTION}");
+ };
+
+ export function loader() {
+ return new Response("${PARENT_LAYOUT_LOADER}");
+ };
+
+ export default function Parent() {
+ return {fetcher.data}
+
+
+
+
+
+
+
+ >
+ );
+ }
+ `,
+
+ "app/routes/fetcher-echo.tsx": js`
+ import { useFetcher } from "react-router";
+
+ export async function action({ request }) {
+ await new Promise(r => setTimeout(r, 1000));
+ let contentType = request.headers.get('Content-Type');
+ let value;
+ if (contentType.includes('application/json')) {
+ let json = await request.json();
+ value = json === null ? json : json.value;
+ } else if (contentType.includes('text/plain')) {
+ value = await request.text();
+ } else {
+ value = (await request.formData()).get('value');
+ }
+ return { data: "ACTION (" + contentType + ") " + value }
+ }
+
+ export async function loader({ request }) {
+ await new Promise(r => setTimeout(r, 1000));
+ let value = new URL(request.url).searchParams.get('value');
+ return { data: "LOADER " + value }
+ }
+
+ export default function Index() {
+ let fetcherValues = [];
+ if (typeof window !== 'undefined') {
+ if (!window.fetcherValues) {
+ window.fetcherValues = [];
+ }
+ fetcherValues = window.fetcherValues
+ }
+
+ let fetcher = useFetcher();
+
+ let currentValue = fetcher.state + '/' + fetcher.data?.data;
+ if (fetcherValues[fetcherValues.length - 1] !== currentValue) {
+ fetcherValues.push(currentValue)
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {fetcher.state === 'idle' ? IDLE
: null} +{JSON.stringify(fetcherValues)}
+ >
+ );
+ }
+ `,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => {
+ appFixture.close();
+ });
+
+ test.describe("No JavaScript", () => {
+ test.use({ javaScriptEnabled: false });
+
+ test("Form can hit a loader", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+
+ await Promise.all([
+ page.waitForNavigation(),
+ app.clickSubmitButton("/resource-route", {
+ wait: false,
+ method: "get",
+ }),
+ ]);
+ // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
+ // a but Edge puts it in some weird code editor markup: + // + //+"LUNCH"+ await page.getByText(LUNCH); + }); + + test("Form can hit an action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await Promise.all([ + page.waitForNavigation({ waitUntil: "load" }), + app.clickSubmitButton("/resource-route", { + wait: false, + method: "post", + }), + ]); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // abut Edge puts it in some weird code editor markup: + // + //"LUNCH"+ await page.getByText(CHEESESTEAK); + }); + }); + + test("load can hit a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#fetcher-load"); + await page.waitForSelector(`pre:has-text("${LUNCH}")`); + }); + + test("submit can hit an action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#fetcher-submit"); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("submit can hit an action with json", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-echo", true); + await page.fill("#fetcher-input", "input value"); + await app.clickElement("#fetcher-submit-json"); + await page.waitForSelector(`#fetcher-idle`); + await page.getByText('ACTION (application/json) input value"'); + }); + + test("submit can hit an action with null json", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-echo", true); + await app.clickElement("#fetcher-submit-json-null"); + await new Promise((r) => setTimeout(r, 1000)); + await page.waitForSelector(`#fetcher-idle`); + await page.getByText('ACTION (application/json) null"'); + }); + + test("submit can hit an action with text", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-echo", true); + await page.fill("#fetcher-input", "input value"); + await app.clickElement("#fetcher-submit-text"); + await page.waitForSelector(`#fetcher-idle`); + await page.getByText('ACTION (text/plain;charset=UTF-8) input value"'); + }); + + test("submit can hit an action with empty text", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-echo", true); + await app.clickElement("#fetcher-submit-text-empty"); + await new Promise((r) => setTimeout(r, 1000)); + await page.waitForSelector(`#fetcher-idle`); + await page.getByText('ACTION (text/plain;charset=UTF-8) "'); + }); + + test("submit can hit an action only route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-action-only-call"); + await app.clickElement("#fetcher-submit"); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("fetchers handle ?index param correctly", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + + await app.clickElement("#load-parent"); + await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`); + + await app.clickElement("#load-index"); + await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`); + + // fetcher.submit({}) defaults to GET for the current Route + await app.clickElement("#submit-empty"); + await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`); + + await app.clickElement("#submit-parent-get"); + await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`); + + await app.clickElement("#submit-index-get"); + await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`); + + await app.clickElement("#submit-parent-post"); + await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`); + + await app.clickElement("#submit-index-post"); + await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`); + }); + + test("fetcher.load persists data through reloads", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/fetcher-echo", true); + await page.getByText(JSON.stringify(["idle/undefined"])); + + await page.fill("#fetcher-input", "1"); + await app.clickElement("#fetcher-load"); + await page.waitForSelector("#fetcher-idle"); + await page.getByText( + JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"]), + ); + + await page.fill("#fetcher-input", "2"); + await app.clickElement("#fetcher-load"); + await page.waitForSelector("#fetcher-idle"); + await page.getByText( + JSON.stringify([ + "idle/undefined", + "loading/undefined", + "idle/LOADER 1", + "loading/LOADER 1", // Preserves old data during reload + "idle/LOADER 2", + ]), + ); + }); + + test("fetcher.submit persists data through resubmissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/fetcher-echo", true); + await page.getByText(JSON.stringify(["idle/undefined"])); + + await page.fill("#fetcher-input", "1"); + await app.clickElement("#fetcher-submit"); + await page.waitForSelector("#fetcher-idle"); + await page.getByText( + JSON.stringify([ + "idle/undefined", + "submitting/undefined", + "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1", + "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1", + ]), + ); + + await page.fill("#fetcher-input", "2"); + await app.clickElement("#fetcher-submit"); + await page.waitForSelector("#fetcher-idle"); + await page.getByText( + JSON.stringify([ + "idle/undefined", + "submitting/undefined", + "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1", + "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1", + // Preserves old data during resubmissions + "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1", + "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2", + "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2", + ]), + ); + }); +}); + +test.describe("fetcher aborts and adjacent forms", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import * as React from "react"; + import { + Form, + useFetcher, + useLoaderData, + useNavigation + } from "react-router"; + + export async function loader({ request }) { + // 1 second timeout on data + await new Promise((r) => setTimeout(r, 1000)); + return { foo: 'bar' }; + } + + export default function Index() { + const [open, setOpen] = React.useState(true); + const { data } = useLoaderData(); + const navigation = useNavigation(); + + return ( ++ {navigation.state === 'idle' &&+ ); + } + + function Child({ onClose }) { + const fetcher = useFetcher(); + + return ( +Idle} + + + + {open &&setOpen(false)} />} + + + + + ); + } + `, + + "app/routes/api.tsx": js` + export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { message: 'Hello world!' } + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("#submit-and-close"); + + // Works as expected before the fetcher is loaded + + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for our navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + + // Breaks after the fetcher is loaded + + // re-mount the fetcher form + await app.clickElement("#open"); + await page.waitForSelector("#submit-and-close"); + // submit the fetcher form + await app.clickElement("#submit-fetcher"); + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + }); +}); + +test.describe("fetcher lazy route discovery", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + test("skips revalidation of initial load fetchers performing lazy route discovery", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + "app/routes/parent.tsx": js` + import * as React from "react"; + import { useFetcher, useNavigate, Outlet } from "react-router"; + + export default function Index() { + const fetcher = useFetcher(); + const navigate = useNavigate(); + + React.useEffect(() => { + fetcher.load('/api'); + }, []); + + React.useEffect(() => { + navigate('/parent/child'); + }, []); + + return ( + <> +Parent
+ {fetcher.data ? +{fetcher.data}: + null} ++ > + ); + } + `, + "app/routes/parent.child.tsx": js` + export default function Index() { + return Child
; + } + `, + "app/routes/api.tsx": js` + export async function loader() { + return "FETCHED!" + } + `, + }, + }); + + // Slow down the fetcher discovery a tiny bit so it doesn't resolve prior + // to the navigation + page.route(/\/__manifest/, async (route) => { + if (route.request().url().includes(encodeURIComponent("/api"))) { + await new Promise((r) => setTimeout(r, 100)); + } + route.continue(); + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await page.waitForSelector("h2", { timeout: 3000 }); + await expect(page.locator("h2")).toHaveText("Child"); + await page.waitForSelector("[data-fetcher]", { timeout: 3000 }); + await expect(page.locator("[data-fetcher]")).toHaveText("FETCHED!"); + }); +}); diff --git a/tests/react-router-framework/integration/fog-of-war-test.ts b/tests/react-router-framework/integration/fog-of-war-test.ts new file mode 100644 index 00000000..03b29b05 --- /dev/null +++ b/tests/react-router-framework/integration/fog-of-war-test.ts @@ -0,0 +1,1798 @@ +import { test, expect } from "@playwright/test"; +import { PassThrough } from "node:stream"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { reactRouterConfig } from "./helpers/vite.js"; + +function getFiles() { + return { + "app/root.tsx": js` + import * as React from "react"; + import { Link, Links, Meta, Outlet, Scripts } from "react-router"; + export default function Root() { + let [showLink, setShowLink] = React.useState(false); + return ( + + + ++ + + Home
+ /a
+ + {showLink ? /a/b : null} ++ + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return Index
+ } + `, + + "app/routes/a.tsx": js` + import { Link, Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "A LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> +A: {data.message}
+ /a/b ++ > + ) + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "B LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> + B: {data.message}
++ > + ) + } + `, + "app/routes/a.b.c.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "C LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return C: {data.message}
+ } + `, + }; +} + +test.describe("Fog of War", () => { + let oldConsoleError: typeof console.error; + + test.beforeEach(() => { + oldConsoleError = console.error; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test("loads minimal manifest on initial load", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/entry.client.tsx": js` + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + startTransition(() => { + hydrateRoot( + document, ++ + ); + }); + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + let res = await fixture.requestDocument("/"); + let html = await res.text(); + + expect(html).toContain("window.__reactRouterManifest = {"); + expect(html).not.toContain( + 'A: A LOADER`); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toContain("routes/a"); + }); + + test("prefetches initially rendered links", async ({ page }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`+ A: A LOADER
`); + }); + + test("prefetches links rendered via navigations", async ({ page }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a.b"], + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches links rendered via in-page stateful updates", async ({ + page, + }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a.b"], + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches links who opt-into [data-discover] via an in-page stateful update", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + ++ + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from 'react'; + import { Link, Outlet, useLoaderData } from "react-router"; + export default function Index() { + let [discover, setDiscover] = React.useState(false) + return ( + <> + /a + + > + ) + } + `, + "app/routes/a.tsx": js` + export default function Index() { + return A
+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a"], + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + }); + + test('does not prefetch links with discover="none"', async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/a.tsx": js` + import { Link, Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "A LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> +A: {data.message}
+ /a/b ++ > + ) + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + // /a/b is not discovered yet even thought it's rendered + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a"]); + + // /a/b gets discovered on click + await app.clickLink("/a/b"); + await page.waitForSelector("#b"); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes), + ), + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches initially rendered forms", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/root.tsx": js` + import * as React from "react"; + import { Form, Links, Meta, Outlet, Scripts } from "react-router"; + export default function Root() { + let [showLink, setShowLink] = React.useState(false); + return ( + + + + + + +
Location: {location.pathname + location.search + location.hash}
+ Go to Other +Location: {location.pathname + location.search + location.hash}
+{data}
+ >
+ )
+ }
+ `,
+
+ "app/routes/about.tsx": js`
+ export async function action({ request }) {
+ return { submitted: true };
+ }
+ export default function () {
+ return {JSON.stringify(actionData)} : null}
+
+ {actionData} : null}
+ {loaderData}
+ >
+ )
+ }
+ `,
+
+ "app/routes/submitter.tsx": js`
+ import { Form } from "react-router";
+
+ export default function() {
+ return (
+ <>
+
+
+ >
+ )
+ }
+ `,
+
+ "app/routes/file-upload.tsx": js`
+ import { Form, useSearchParams } from "react-router";
+
+ export default function() {
+ const [params] = useSearchParams();
+ return (
+
+ )
+ }
+ `,
+
+ "app/routes/empty-file-upload.tsx": js`
+ import { Form, useActionData } from "react-router";
+
+ export async function action({ request }) {
+ let formData = await request.formData();
+ return {
+ text: formData.get('text'),
+ file: {
+ name: formData.get('file').name,
+ size: formData.get('file').size,
+ },
+ fileMultiple: formData.getAll('fileMultiple').map(f => ({
+ name: f.name,
+ size: f.size,
+ })),
+ }
+ }
+
+ export default function() {
+ const actionData = useActionData();
+ return (
+
+ )
+ }
+ `,
+
+ // Generic route for outputting url-encoded form data (either from the request body or search params)
+ //
+ // TODO: refactor other tests to use this
+ "app/routes/outputFormData.tsx": js`
+ import { useActionData, useSearchParams } from "react-router";
+
+ export async function action({ request }) {
+ const formData = await request.formData();
+ const body = new URLSearchParams();
+ for (let [key, value] of formData) {
+ body.append(
+ key,
+ value instanceof File ? await streamToString(value.stream()) : value
+ );
+ }
+ return body.toString();
+ }
+
+ export default function OutputFormData() {
+ const requestBody = useActionData();
+ const searchParams = useSearchParams()[0];
+ return ;
+ }
+ `,
+
+ "myfile.txt": "stuff",
+
+ "app/routes/pathless-layout-parent.tsx": js`
+ import { Form, Outlet, useActionData } from "react-router"
+
+ export async function action({ request }) {
+ return { submitted: true };
+ }
+ export default function () {
+ let data = useActionData();
+ return (
+ <>
+
+ {data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}
+ > + ); + } + `, + + "app/routes/pathless-layout-parent._pathless.nested.tsx": js` + import { Outlet } from "react-router"; + + export default function () { + return ( + <> +