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}

+ + + ); + }} + /> +
+ ) + ); + + 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}

+ + + ); + }} + > + { + 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) ? (