Skip to content

Commit 5ff7203

Browse files
committed
Allow useRevalidate to resolve a loader-driven error boundary UI
1 parent 95a295c commit 5ff7203

File tree

3 files changed

+150
-142
lines changed

3 files changed

+150
-142
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Allow `useRevalidate()` to resolve a loader-driven error boundary scenario

packages/react-router/__tests__/data-memory-router-test.tsx

Lines changed: 134 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,37 +1747,11 @@ describe("createMemoryRouter", () => {
17471747
);
17481748
let { container } = render(<RouterProvider router={router} />);
17491749

1750-
expect(getHtml(container)).toMatchInlineSnapshot(`
1751-
"<div>
1752-
<h2>
1753-
Unexpected Application Error!
1754-
</h2>
1755-
<h3
1756-
style="font-style: italic;"
1757-
>
1758-
404 Not Found
1759-
</h3>
1760-
<p>
1761-
💿 Hey developer 👋
1762-
</p>
1763-
<p>
1764-
You can provide a way better UX than this when your app throws errors by providing your own
1765-
<code
1766-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
1767-
>
1768-
ErrorBoundary
1769-
</code>
1770-
or
1771-
1772-
<code
1773-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
1774-
>
1775-
errorElement
1776-
</code>
1777-
prop on your route.
1778-
</p>
1779-
</div>"
1780-
`);
1750+
let html = getHtml(container);
1751+
expect(html).toMatch("Unexpected Application Error!");
1752+
expect(html).toMatch("404 Not Found");
1753+
expect(html).toMatch("💿 Hey developer 👋");
1754+
expect(html).not.toMatch(/stack/i);
17811755
});
17821756

17831757
it("renders navigation errors with a default if no errorElements are provided", async () => {
@@ -1861,42 +1835,11 @@ describe("createMemoryRouter", () => {
18611835
error.stack = "FAKE STACK TRACE";
18621836
barDefer.reject(error);
18631837
await waitFor(() => screen.getByText("Kaboom!"));
1864-
expect(getHtml(container)).toMatchInlineSnapshot(`
1865-
"<div>
1866-
<h2>
1867-
Unexpected Application Error!
1868-
</h2>
1869-
<h3
1870-
style="font-style: italic;"
1871-
>
1872-
Kaboom!
1873-
</h3>
1874-
<pre
1875-
style="padding: 0.5rem; background-color: rgba(200, 200, 200, 0.5);"
1876-
>
1877-
FAKE STACK TRACE
1878-
</pre>
1879-
<p>
1880-
💿 Hey developer 👋
1881-
</p>
1882-
<p>
1883-
You can provide a way better UX than this when your app throws errors by providing your own
1884-
<code
1885-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
1886-
>
1887-
ErrorBoundary
1888-
</code>
1889-
or
1890-
1891-
<code
1892-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
1893-
>
1894-
errorElement
1895-
</code>
1896-
prop on your route.
1897-
</p>
1898-
</div>"
1899-
`);
1838+
let html = getHtml(container);
1839+
expect(html).toMatch("Unexpected Application Error!");
1840+
expect(html).toMatch("Kaboom!");
1841+
expect(html).toMatch("FAKE STACK TRACE");
1842+
expect(html).toMatch("💿 Hey developer 👋");
19001843
});
19011844

19021845
// This test ensures that when manual routes are used, we add hasErrorBoundary
@@ -2095,42 +2038,11 @@ describe("createMemoryRouter", () => {
20952038
throw error;
20962039
}
20972040

2098-
expect(getHtml(container)).toMatchInlineSnapshot(`
2099-
"<div>
2100-
<h2>
2101-
Unexpected Application Error!
2102-
</h2>
2103-
<h3
2104-
style="font-style: italic;"
2105-
>
2106-
Kaboom!
2107-
</h3>
2108-
<pre
2109-
style="padding: 0.5rem; background-color: rgba(200, 200, 200, 0.5);"
2110-
>
2111-
FAKE STACK TRACE
2112-
</pre>
2113-
<p>
2114-
💿 Hey developer 👋
2115-
</p>
2116-
<p>
2117-
You can provide a way better UX than this when your app throws errors by providing your own
2118-
<code
2119-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
2120-
>
2121-
ErrorBoundary
2122-
</code>
2123-
or
2124-
2125-
<code
2126-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
2127-
>
2128-
errorElement
2129-
</code>
2130-
prop on your route.
2131-
</p>
2132-
</div>"
2133-
`);
2041+
let html = getHtml(container);
2042+
expect(html).toMatch("Unexpected Application Error!");
2043+
expect(html).toMatch("Kaboom!");
2044+
expect(html).toMatch("FAKE STACK TRACE");
2045+
expect(html).toMatch("💿 Hey developer 👋");
21342046
});
21352047

21362048
it("does not handle render errors for non-data routers", async () => {
@@ -2280,44 +2192,11 @@ describe("createMemoryRouter", () => {
22802192

22812193
router.navigate("/child");
22822194
await waitFor(() => screen.getByText("Kaboom!"));
2283-
expect(getHtml(container)).toMatchInlineSnapshot(`
2284-
"<div>
2285-
<div>
2286-
<h2>
2287-
Unexpected Application Error!
2288-
</h2>
2289-
<h3
2290-
style="font-style: italic;"
2291-
>
2292-
Kaboom!
2293-
</h3>
2294-
<pre
2295-
style="padding: 0.5rem; background-color: rgba(200, 200, 200, 0.5);"
2296-
>
2297-
FAKE STACK TRACE
2298-
</pre>
2299-
<p>
2300-
💿 Hey developer 👋
2301-
</p>
2302-
<p>
2303-
You can provide a way better UX than this when your app throws errors by providing your own
2304-
<code
2305-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
2306-
>
2307-
ErrorBoundary
2308-
</code>
2309-
or
2310-
2311-
<code
2312-
style="padding: 2px 4px; background-color: rgba(200, 200, 200, 0.5);"
2313-
>
2314-
errorElement
2315-
</code>
2316-
prop on your route.
2317-
</p>
2318-
</div>
2319-
</div>"
2320-
`);
2195+
let html = getHtml(container);
2196+
expect(html).toMatch("Unexpected Application Error!");
2197+
expect(html).toMatch("Kaboom!");
2198+
expect(html).toMatch("FAKE STACK TRACE");
2199+
expect(html).toMatch("💿 Hey developer 👋");
23212200

23222201
router.navigate(-1);
23232202
await waitFor(() => {
@@ -2508,6 +2387,120 @@ describe("createMemoryRouter", () => {
25082387
);
25092388
errorSpy.mockRestore();
25102389
});
2390+
2391+
it("allows a successful useRevalidator to resolve the error boundary (loader + child boundary)", async () => {
2392+
let shouldFail = true;
2393+
let router = createMemoryRouter(
2394+
createRoutesFromElements(
2395+
<Route
2396+
path="/"
2397+
Component={() => (
2398+
<>
2399+
<MemoryNavigate to="child">/child</MemoryNavigate>
2400+
<Outlet />
2401+
</>
2402+
)}
2403+
>
2404+
<Route
2405+
path="child"
2406+
loader={() => {
2407+
if (shouldFail) {
2408+
shouldFail = false;
2409+
throw new Error("Broken");
2410+
} else {
2411+
return "Fixed";
2412+
}
2413+
}}
2414+
Component={() => <p>{("Child:" + useLoaderData()) as string}</p>}
2415+
ErrorBoundary={() => {
2416+
let { revalidate } = useRevalidator();
2417+
return (
2418+
<>
2419+
<p>{"Error:" + (useRouteError() as Error).message}</p>
2420+
<button onClick={() => revalidate()}>Try again</button>
2421+
</>
2422+
);
2423+
}}
2424+
/>
2425+
</Route>
2426+
)
2427+
);
2428+
2429+
let { container } = render(
2430+
<div>
2431+
<RouterProvider router={router} />
2432+
</div>
2433+
);
2434+
2435+
fireEvent.click(screen.getByText("/child"));
2436+
await waitFor(() => screen.getByText("Error:Broken"));
2437+
expect(getHtml(container)).toMatch("Error:Broken");
2438+
expect(router.state.errors).not.toBe(null);
2439+
2440+
fireEvent.click(screen.getByText("Try again"));
2441+
await waitFor(() => {
2442+
expect(queryByText(container, "Child:Fixed")).toBeInTheDocument();
2443+
});
2444+
expect(getHtml(container)).toMatch("Child:Fixed");
2445+
expect(router.state.errors).toBe(null);
2446+
});
2447+
2448+
it("allows a successful useRevalidator to resolve the error boundary (loader + parent boundary)", async () => {
2449+
let shouldFail = true;
2450+
let router = createMemoryRouter(
2451+
createRoutesFromElements(
2452+
<Route
2453+
path="/"
2454+
Component={() => (
2455+
<>
2456+
<MemoryNavigate to="child">/child</MemoryNavigate>
2457+
<Outlet />
2458+
</>
2459+
)}
2460+
ErrorBoundary={() => {
2461+
let { revalidate } = useRevalidator();
2462+
return (
2463+
<>
2464+
<p>{"Error:" + (useRouteError() as Error).message}</p>
2465+
<button onClick={() => revalidate()}>Try again</button>
2466+
</>
2467+
);
2468+
}}
2469+
>
2470+
<Route
2471+
path="child"
2472+
loader={() => {
2473+
if (shouldFail) {
2474+
shouldFail = false;
2475+
throw new Error("Broken");
2476+
} else {
2477+
return "Fixed";
2478+
}
2479+
}}
2480+
Component={() => <p>{("Child:" + useLoaderData()) as string}</p>}
2481+
/>
2482+
</Route>
2483+
)
2484+
);
2485+
2486+
let { container } = render(
2487+
<div>
2488+
<RouterProvider router={router} />
2489+
</div>
2490+
);
2491+
2492+
fireEvent.click(screen.getByText("/child"));
2493+
await waitFor(() => screen.getByText("Error:Broken"));
2494+
expect(getHtml(container)).toMatch("Error:Broken");
2495+
expect(router.state.errors).not.toBe(null);
2496+
2497+
fireEvent.click(screen.getByText("Try again"));
2498+
await waitFor(() => {
2499+
expect(queryByText(container, "Child:Fixed")).toBeInTheDocument();
2500+
});
2501+
expect(getHtml(container)).toMatch("Child:Fixed");
2502+
expect(router.state.errors).toBe(null);
2503+
});
25112504
});
25122505

25132506
describe("defer", () => {

packages/react-router/lib/hooks.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
PathPattern,
1111
RelativeRoutingType,
1212
Router as RemixRouter,
13+
RevalidationState,
1314
To,
1415
} from "@remix-run/router";
1516
import {
@@ -506,13 +507,15 @@ const defaultErrorElement = <DefaultErrorComponent />;
506507

507508
type RenderErrorBoundaryProps = React.PropsWithChildren<{
508509
location: Location;
510+
revalidation: RevalidationState;
509511
error: any;
510512
component: React.ReactNode;
511513
routeContext: RouteContextObject;
512514
}>;
513515

514516
type RenderErrorBoundaryState = {
515517
location: Location;
518+
revalidation: RevalidationState;
516519
error: any;
517520
};
518521

@@ -524,6 +527,7 @@ export class RenderErrorBoundary extends React.Component<
524527
super(props);
525528
this.state = {
526529
location: props.location,
530+
revalidation: props.revalidation,
527531
error: props.error,
528532
};
529533
}
@@ -544,10 +548,14 @@ export class RenderErrorBoundary extends React.Component<
544548
// Whether we're in an error state or not, we update the location in state
545549
// so that when we are in an error state, it gets reset when a new location
546550
// comes in and the user recovers from the error.
547-
if (state.location !== props.location) {
551+
if (
552+
state.location !== props.location ||
553+
(state.revalidation !== "idle" && props.revalidation === "idle")
554+
) {
548555
return {
549556
error: props.error,
550557
location: props.location,
558+
revalidation: props.revalidation,
551559
};
552560
}
553561

@@ -558,6 +566,7 @@ export class RenderErrorBoundary extends React.Component<
558566
return {
559567
error: props.error || state.error,
560568
location: state.location,
569+
revalidation: props.revalidation || state.revalidation,
561570
};
562571
}
563572

@@ -675,6 +684,7 @@ export function _renderMatches(
675684
(match.route.ErrorBoundary || match.route.errorElement || index === 0) ? (
676685
<RenderErrorBoundary
677686
location={dataRouterState.location}
687+
revalidation={dataRouterState.revalidation}
678688
component={errorElement}
679689
error={error}
680690
children={getChildren()}

0 commit comments

Comments
 (0)