{message}
-Did client loader run? {client ? "Yes" : "No"}
-diff --git a/.eslintignore b/.eslintignore
index 49a812d5b4..e293384554 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -14,7 +14,6 @@ build.utils.d.ts
.wrangler/
.tmp/
.react-router/
-.react-router-parcel/
packages/**/dist/
packages/react-router-dom/server.d.ts
packages/react-router-dom/server.js
diff --git a/.gitignore b/.gitignore
index 53e9a5baf1..9209f20a17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,7 +27,6 @@ node_modules/
.wireit
.eslintcache
-.parcel-cache
.tmp
tsup.config.bundled_*.mjs
build.utils.d.ts
@@ -36,4 +35,4 @@ worker-configuration.d.ts
/NOTES.md
# v7 reference docs
-/public
\ No newline at end of file
+/public
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..0758c92192
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,160 @@
+# React Router Development Guide
+
+## Commands
+
+- **Build**: `pnpm build` (all packages) or `pnpm run --filter
-React Router catches errors in your route modules and sends them to [error boundaries](./error-boundary) to prevent blank pages when errors occur. However, ErrorBoundary isn't sufficient for logging and reporting errors. To access these caught errors, use the handleError export of the server entry module.
+React Router catches errors in your route modules and sends them to [error boundaries](./error-boundary) to prevent blank pages when errors occur. However, `ErrorBoundary` isn't sufficient for logging and reporting errors.
-## 1. Reveal the server entry
+## Server Errors
-If you don't see `entry.server.tsx` in your app directory, you're using a default entry. Reveal it with this cli command:
+[modes: framework]
+
+To access these caught errors on the server, use the `handleError` export of the server entry module.
+
+### 1. Reveal the server entry
+
+If you don't see [`entry.server.tsx`][entryserver] in your app directory, you're using a default entry. Reveal it with this cli command:
```shellscript nonumber
-react-router reveal
+react-router reveal entry.server
```
-## 2. Export your error handler
+### 2. Export your error handler
This function is called whenever React Router catches an error in your application on the server.
@@ -39,3 +45,90 @@ export const handleError: HandleErrorFunction = (
}
};
```
+
+See also:
+
+- [`handleError`][handleError]
+
+## Client Errors
+
+To access these caught errors on the client, use the `onError` prop on your [`HydratedRouter`][hydratedrouter] or [`RouterProvider`][routerprovider] component.
+
+### Framework Mode
+
+[modes: framework]
+
+#### 1. Reveal the client entry
+
+If you don't see [`entry.client.tsx`][entryclient] in your app directory, you're using a default entry. Reveal it with this cli command:
+
+```shellscript nonumber
+react-router reveal entry.client
+```
+
+#### 2. Add your error handler
+
+This function is called whenever React Router catches an error in your application on the client.
+
+```tsx filename=entry.client.tsx
+import { type ClientOnErrorFunction } from "react-router";
+
+const onError: ClientOnErrorFunction = (
+ error,
+ { location, params, unstable_pattern, errorInfo },
+) => {
+ myReportError(error, location, errorInfo);
+
+ // make sure to still log the error so you can see it
+ console.error(error, errorInfo);
+};
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+ Home
;
-}
diff --git a/integration/helpers/rsc-parcel/src/routes/root.tsx b/integration/helpers/rsc-parcel/src/routes/root.tsx
deleted file mode 100644
index 1482b6c0a6..0000000000
--- a/integration/helpers/rsc-parcel/src/routes/root.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Links, Outlet, ScrollRestoration } from "react-router";
-
-export function Layout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
- {id || "home"}
+ Redirect
+ External
+ >
+ )
+ }
+ `,
+ "src/routes/render-redirect/lazy.tsx": js`
+ import { Suspense } from "react";
+ import { Link, redirect } from "react-router";
+
+ export default function RenderRedirect({ params: { id } }) {
+ return (
+
{error.status} {error.statusText} {error.data?.message || "no"}
; + } + returnOh no D:
; + } + `, }, }); }); @@ -1615,11 +1704,6 @@ implementations.forEach((implementation) => { }); test("Supports client context using getContext", async ({ page }) => { - test.skip( - implementation.name === "parcel", - "Parcel is having trouble resolving modules, should probably file a bug report for this.", - ); - await page.goto(`http://localhost:${port}/get-context`); await page.waitForSelector("[data-client-context]"); expect( @@ -1738,6 +1822,76 @@ implementations.forEach((implementation) => { "An error occurred in the Server Components render.", ); }); + + test("Suppport throwing redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Redirect").click(); + await page.waitForURL( + `http://localhost:${port}/render-redirect/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from render", async ({ + browserName, + page, + }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", + ); + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("External").click(); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); + + test("Suppport throwing redirect Response from suspended render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect/lazy`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Redirect").click(); + await page.waitForURL( + `http://localhost:${port}/render-redirect/lazy/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from suspended render", async ({ + browserName, + page, + }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", + ); + await page.goto(`http://localhost:${port}/render-redirect/lazy`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("External").click(); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); + + test("Support throwing Responses", async ({ page }) => { + await page.goto( + `http://localhost:${port}/render-route-error-response`, + ); + await expect(page.getByText("400 Oh no! no")).toBeAttached(); + }); + + test("Support throwing data() responses with data", async ({ + page, + }) => { + await page.goto( + `http://localhost:${port}/render-route-error-response/Test`, + ); + await expect(page.getByText("400 Oh no! Test")).toBeAttached(); + }); }); test.describe("Server Actions", () => { @@ -1765,12 +1919,6 @@ implementations.forEach((implementation) => { }); test("Supports Inline React Server Functions", async ({ page }) => { - // FIXME: Waiting on parcel support: https://github.com/parcel-bundler/parcel/pull/10165 - test.skip( - implementation.name === "parcel", - "Not supported in parcel yet", - ); - await page.goto(`http://localhost:${port}/inline-server-action/`); // Verify initial server render @@ -1837,9 +1985,6 @@ implementations.forEach((implementation) => { test("Supports React Server Functions thrown external redirects", async ({ page, }) => { - // Test is expected to fail currently — skip running it - // test.skip(true, "Known failing test for external redirect behavior"); - await page.goto( `http://localhost:${port}/throw-external-redirect-server-action/`, ); @@ -1864,8 +2009,6 @@ implementations.forEach((implementation) => { test("Supports React Server Functions side-effect redirects", async ({ page, }) => { - test.skip(implementation.name === "parcel", "Not working in parcel?"); - await page.goto( `http://localhost:${port}/side-effect-redirect-server-action`, ); @@ -1917,9 +2060,6 @@ implementations.forEach((implementation) => { test("Supports React Server Functions side-effect external redirects", async ({ page, }) => { - // Test is expected to fail currently — skip running it - test.skip(implementation.name === "parcel", "Not working in parcel?"); - await page.goto( `http://localhost:${port}/side-effect-external-redirect-server-action`, ); @@ -2001,7 +2141,9 @@ implementations.forEach((implementation) => { await page.click("[data-submit]"); await page.waitForSelector("[data-state]"); await page.waitForSelector("[data-pending]", { state: "hidden" }); - await page.waitForSelector("[data-revalidated]", { state: "hidden" }); + await page.waitForSelector("[data-revalidated]", { + state: "hidden", + }); expect(await page.locator("[data-state]").textContent()).toBe( "no revalidate", ); @@ -2013,11 +2155,6 @@ implementations.forEach((implementation) => { test("Supports transition state throughout the revalidation lifecycle", async ({ page, }) => { - test.skip( - implementation.name === "parcel", - "Uses inline server actions which parcel doesn't support yet", - ); - await page.goto(`http://localhost:${port}/action-transition-state`, { waitUntil: "networkidle", }); @@ -2069,10 +2206,6 @@ implementations.forEach((implementation) => { test("Handles errors thrown in SSR components correctly", async ({ page, }) => { - test.skip( - implementation.name === "parcel", - "Parcel's error overlays are interfering with this test", - ); await page.goto(`http://localhost:${port}/ssr-error`); // Verify error boundary is shown @@ -2527,8 +2660,6 @@ implementations.forEach((implementation) => { test("Supports redirects in server actions without JavaScript with basename", async ({ page, }) => { - test.skip(implementation.name === "parcel", "Not working in parcel?"); - // Start on home route await page.goto( `http://localhost:${port}${basename}/server-action-redirects`, diff --git a/integration/rsc/utils.ts b/integration/rsc/utils.ts index 8a4a753a21..9d86ee681b 100644 --- a/integration/rsc/utils.ts +++ b/integration/rsc/utils.ts @@ -20,7 +20,6 @@ export type Implementation = { dev: ({ cwd, port }: { cwd: string; port: number }) => Promise<() => void>; }; -// Run tests against vite and parcel to ensure our code is bundler agnostic export const implementations: Implementation[] = [ { name: "vite", @@ -40,28 +39,6 @@ export const implementations: Implementation[] = [ port, }), }, - { - name: "parcel", - template: "rsc-parcel", - build: ({ cwd }: { cwd: string }) => spawnSync("pnpm", ["build"], { cwd }), - run: ({ cwd, port }) => - createDev(["dist/server/server.js", "-p", String(port)])({ - cwd, - port, - env: { - NODE_ENV: "production", - }, - }), - dev: ({ cwd, port }) => - createDev(["node_modules/parcel/lib/bin.js"])({ - // Since we run through parcels dev server we can't use `-p` because that - // only changes the dev server and doesn't pass through to the internal - // server. So we setup the internal server to choose from `RR_PORT` - env: { RR_PORT: String(port) }, - cwd, - port, - }), - }, ] as Implementation[]; export async function setupRscTest({ diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index b02c25643c..b4db12bc26 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -837,6 +837,232 @@ test.describe("single-fetch", () => { expect(urls).toEqual([]); }); + test("supports call-site revalidation opt-out on submissions (w/o shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form } from 'react-router'; + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export function action() { + return { count: ++count }; + } + + export default function Comp({ loaderData, actionData }) { + return ( + + ); + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="name"][value="value"]'); + await page.waitForSelector("#action-data"); + expect(await app.getHtml("#action-data")).toContain("2"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + }); + + test("supports call-site revalidation opt-in on 4xx/5xx action responses (w/o shouldRevalidate)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/action.tsx": js` + import { Form, Link, useNavigation, data } from 'react-router'; + + export async function action({ request }) { + throw data("Thrown 500", { status: 500 }); + } + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export default function Comp({ loaderData }) { + let navigation = useNavigation(); + return ( + + ); + } + + export function ErrorBoundary() { + returnEnvironment: production
+This is the about page
+{message}
+{message}
+{timestamp}
+{productId}
+Product {productId}
+Current count: {count}
- -Did client loader run? {client ? "Yes" : "No"}
-{JSON.stringify(fetcher.data, null, 2)}
- Did client loader run? {client ? "Yes" : "No"}
-Loader data: {loaderData.message}
+ {loaderData.counter} +Loader data: {loaderData.message}
- {loaderData.counter} -Loader data: {loaderData.message}
+// {loaderData.counter} +//