diff --git a/.changeset/revalidate-error-boundary.md b/.changeset/revalidate-error-boundary.md
new file mode 100644
index 0000000000..77fb52f5cd
--- /dev/null
+++ b/.changeset/revalidate-error-boundary.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+Allow `useRevalidator()` to resolve a loader-driven error boundary scenario
diff --git a/package.json b/package.json
index 8cbae32815..167ae735df 100644
--- a/package.json
+++ b/package.json
@@ -108,10 +108,10 @@
"none": "45.8 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
- "none": "13.3 kB"
+ "none": "13.5 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
- "none": "15.6 kB"
+ "none": "15.8 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "12 kB"
diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx
index 2aae328224..da65602a64 100644
--- a/packages/react-router/__tests__/data-memory-router-test.tsx
+++ b/packages/react-router/__tests__/data-memory-router-test.tsx
@@ -909,7 +909,7 @@ describe("createMemoryRouter", () => {
});
});
- it("reloads data using useRevalidate", async () => {
+ it("reloads data using useRevalidator", async () => {
let count = 1;
let router = createMemoryRouter(
createRoutesFromElements(
@@ -1747,37 +1747,11 @@ describe("createMemoryRouter", () => {
);
let { container } = render( );
- expect(getHtml(container)).toMatchInlineSnapshot(`
- "
-
- Unexpected Application Error!
-
-
- 404 Not Found
-
-
- 💿 Hey developer 👋
-
-
- You can provide a way better UX than this when your app throws errors by providing your own
-
- ErrorBoundary
-
- or
-
-
- errorElement
-
- prop on your route.
-
-
"
- `);
+ let html = getHtml(container);
+ expect(html).toMatch("Unexpected Application Error!");
+ expect(html).toMatch("404 Not Found");
+ expect(html).toMatch("💿 Hey developer 👋");
+ expect(html).not.toMatch(/stack/i);
});
it("renders navigation errors with a default if no errorElements are provided", async () => {
@@ -1861,42 +1835,11 @@ describe("createMemoryRouter", () => {
error.stack = "FAKE STACK TRACE";
barDefer.reject(error);
await waitFor(() => screen.getByText("Kaboom!"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
- "
-
- Unexpected Application Error!
-
-
- Kaboom!
-
-
- FAKE STACK TRACE
-
-
- 💿 Hey developer 👋
-
-
- You can provide a way better UX than this when your app throws errors by providing your own
-
- ErrorBoundary
-
- or
-
-
- errorElement
-
- prop on your route.
-
-
"
- `);
+ let html = getHtml(container);
+ expect(html).toMatch("Unexpected Application Error!");
+ expect(html).toMatch("Kaboom!");
+ expect(html).toMatch("FAKE STACK TRACE");
+ expect(html).toMatch("💿 Hey developer 👋");
});
// This test ensures that when manual routes are used, we add hasErrorBoundary
@@ -2095,42 +2038,11 @@ describe("createMemoryRouter", () => {
throw error;
}
- expect(getHtml(container)).toMatchInlineSnapshot(`
- "
-
- Unexpected Application Error!
-
-
- Kaboom!
-
-
- FAKE STACK TRACE
-
-
- 💿 Hey developer 👋
-
-
- You can provide a way better UX than this when your app throws errors by providing your own
-
- ErrorBoundary
-
- or
-
-
- errorElement
-
- prop on your route.
-
-
"
- `);
+ let html = getHtml(container);
+ expect(html).toMatch("Unexpected Application Error!");
+ expect(html).toMatch("Kaboom!");
+ expect(html).toMatch("FAKE STACK TRACE");
+ expect(html).toMatch("💿 Hey developer 👋");
});
it("does not handle render errors for non-data routers", async () => {
@@ -2280,44 +2192,11 @@ describe("createMemoryRouter", () => {
router.navigate("/child");
await waitFor(() => screen.getByText("Kaboom!"));
- expect(getHtml(container)).toMatchInlineSnapshot(`
- "
-
-
- Unexpected Application Error!
-
-
- Kaboom!
-
-
- FAKE STACK TRACE
-
-
- 💿 Hey developer 👋
-
-
- You can provide a way better UX than this when your app throws errors by providing your own
-
- ErrorBoundary
-
- or
-
-
- errorElement
-
- prop on your route.
-
-
-
"
- `);
+ let html = getHtml(container);
+ expect(html).toMatch("Unexpected Application Error!");
+ expect(html).toMatch("Kaboom!");
+ expect(html).toMatch("FAKE STACK TRACE");
+ expect(html).toMatch("💿 Hey developer 👋");
router.navigate(-1);
await waitFor(() => {
@@ -2508,6 +2387,120 @@ describe("createMemoryRouter", () => {
);
errorSpy.mockRestore();
});
+
+ it("allows a successful useRevalidator to resolve the error boundary (loader + child boundary)", async () => {
+ let shouldFail = true;
+ let router = createMemoryRouter(
+ createRoutesFromElements(
+ (
+ <>
+ /child
+
+ >
+ )}
+ >
+ {
+ if (shouldFail) {
+ shouldFail = false;
+ throw new Error("Broken");
+ } else {
+ return "Fixed";
+ }
+ }}
+ Component={() => {("Child:" + useLoaderData()) as string}
}
+ ErrorBoundary={() => {
+ let { revalidate } = useRevalidator();
+ return (
+ <>
+ {"Error:" + (useRouteError() as Error).message}
+ revalidate()}>Try again
+ >
+ );
+ }}
+ />
+
+ )
+ );
+
+ let { container } = render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("/child"));
+ await waitFor(() => screen.getByText("Error:Broken"));
+ expect(getHtml(container)).toMatch("Error:Broken");
+ expect(router.state.errors).not.toBe(null);
+
+ fireEvent.click(screen.getByText("Try again"));
+ await waitFor(() => {
+ expect(queryByText(container, "Child:Fixed")).toBeInTheDocument();
+ });
+ expect(getHtml(container)).toMatch("Child:Fixed");
+ expect(router.state.errors).toBe(null);
+ });
+
+ it("allows a successful useRevalidator to resolve the error boundary (loader + parent boundary)", async () => {
+ let shouldFail = true;
+ let router = createMemoryRouter(
+ createRoutesFromElements(
+ (
+ <>
+ /child
+
+ >
+ )}
+ ErrorBoundary={() => {
+ let { revalidate } = useRevalidator();
+ return (
+ <>
+ {"Error:" + (useRouteError() as Error).message}
+ revalidate()}>Try again
+ >
+ );
+ }}
+ >
+ {
+ if (shouldFail) {
+ shouldFail = false;
+ throw new Error("Broken");
+ } else {
+ return "Fixed";
+ }
+ }}
+ Component={() => {("Child:" + useLoaderData()) as string}
}
+ />
+
+ )
+ );
+
+ let { container } = render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("/child"));
+ await waitFor(() => screen.getByText("Error:Broken"));
+ expect(getHtml(container)).toMatch("Error:Broken");
+ expect(router.state.errors).not.toBe(null);
+
+ fireEvent.click(screen.getByText("Try again"));
+ await waitFor(() => {
+ expect(queryByText(container, "Child:Fixed")).toBeInTheDocument();
+ });
+ expect(getHtml(container)).toMatch("Child:Fixed");
+ expect(router.state.errors).toBe(null);
+ });
});
describe("defer", () => {
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index 4568e09880..3daea2283d 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -10,6 +10,7 @@ import type {
PathPattern,
RelativeRoutingType,
Router as RemixRouter,
+ RevalidationState,
To,
} from "@remix-run/router";
import {
@@ -506,6 +507,7 @@ const defaultErrorElement = ;
type RenderErrorBoundaryProps = React.PropsWithChildren<{
location: Location;
+ revalidation: RevalidationState;
error: any;
component: React.ReactNode;
routeContext: RouteContextObject;
@@ -513,6 +515,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{
type RenderErrorBoundaryState = {
location: Location;
+ revalidation: RevalidationState;
error: any;
};
@@ -524,6 +527,7 @@ export class RenderErrorBoundary extends React.Component<
super(props);
this.state = {
location: props.location,
+ revalidation: props.revalidation,
error: props.error,
};
}
@@ -544,10 +548,14 @@ export class RenderErrorBoundary extends React.Component<
// Whether we're in an error state or not, we update the location in state
// so that when we are in an error state, it gets reset when a new location
// comes in and the user recovers from the error.
- if (state.location !== props.location) {
+ if (
+ state.location !== props.location ||
+ (state.revalidation !== "idle" && props.revalidation === "idle")
+ ) {
return {
error: props.error,
location: props.location,
+ revalidation: props.revalidation,
};
}
@@ -558,6 +566,7 @@ export class RenderErrorBoundary extends React.Component<
return {
error: props.error || state.error,
location: state.location,
+ revalidation: props.revalidation || state.revalidation,
};
}
@@ -675,6 +684,7 @@ export function _renderMatches(
(match.route.ErrorBoundary || match.route.errorElement || index === 0) ? (