Skip to content

Commit e2b982b

Browse files
authored
fix: Capture fetcher errors at contextual route error boundaries (#8945)
* fix: Capture fetcher errors at contextual route error boundaries * add invariants for lazy useState functions
1 parent 49af8da commit e2b982b

File tree

4 files changed

+243
-25
lines changed

4 files changed

+243
-25
lines changed

packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,188 @@ function testDomRouter(name, TestDataRouter, getWindow) {
18071807
</p>"
18081808
`);
18091809
});
1810+
1811+
it("handles fetcher.load errors at the correct spot in the route hierarchy", async () => {
1812+
let { container } = render(
1813+
<TestDataRouter
1814+
window={getWindow("/child")}
1815+
hydrationData={{ loaderData: { "0": null } }}
1816+
>
1817+
<Route path="/" element={<Outlet />} errorElement={<p>Not I!</p>}>
1818+
<Route
1819+
path="child"
1820+
element={<Comp />}
1821+
errorElement={<ErrorElement />}
1822+
/>
1823+
<Route
1824+
path="fetch"
1825+
loader={() => {
1826+
throw new Error("Kaboom!");
1827+
}}
1828+
errorElement={<p>Not I!</p>}
1829+
/>
1830+
</Route>
1831+
</TestDataRouter>
1832+
);
1833+
1834+
function Comp() {
1835+
let fetcher = useFetcher();
1836+
return <button onClick={() => fetcher.load("/fetch")}>load</button>;
1837+
}
1838+
1839+
function ErrorElement() {
1840+
return <p>contextual error:{useRouteError().message}</p>;
1841+
}
1842+
1843+
expect(getHtml(container)).toMatchInlineSnapshot(`
1844+
"<div>
1845+
<button>
1846+
load
1847+
</button>
1848+
</div>"
1849+
`);
1850+
1851+
fireEvent.click(screen.getByText("load"));
1852+
await waitFor(() => screen.getByText(/Kaboom!/));
1853+
expect(getHtml(container)).toMatchInlineSnapshot(`
1854+
"<div>
1855+
<p>
1856+
contextual error:
1857+
Kaboom!
1858+
</p>
1859+
</div>"
1860+
`);
1861+
});
1862+
1863+
it("handles fetcher.submit errors at the correct spot in the route hierarchy", async () => {
1864+
let { container } = render(
1865+
<TestDataRouter
1866+
window={getWindow("/child")}
1867+
hydrationData={{ loaderData: { "0": null } }}
1868+
>
1869+
<Route path="/" element={<Outlet />} errorElement={<p>Not I!</p>}>
1870+
<Route
1871+
path="child"
1872+
element={<Comp />}
1873+
errorElement={<ErrorElement />}
1874+
/>
1875+
<Route
1876+
path="fetch"
1877+
action={() => {
1878+
throw new Error("Kaboom!");
1879+
}}
1880+
errorElement={<p>Not I!</p>}
1881+
/>
1882+
</Route>
1883+
</TestDataRouter>
1884+
);
1885+
1886+
function Comp() {
1887+
let fetcher = useFetcher();
1888+
return (
1889+
<button
1890+
onClick={() =>
1891+
fetcher.submit(
1892+
{ key: "value" },
1893+
{ method: "post", action: "/fetch" }
1894+
)
1895+
}
1896+
>
1897+
submit
1898+
</button>
1899+
);
1900+
}
1901+
1902+
function ErrorElement() {
1903+
return <p>contextual error:{useRouteError().message}</p>;
1904+
}
1905+
1906+
expect(getHtml(container)).toMatchInlineSnapshot(`
1907+
"<div>
1908+
<button>
1909+
submit
1910+
</button>
1911+
</div>"
1912+
`);
1913+
1914+
fireEvent.click(screen.getByText("submit"));
1915+
await waitFor(() => screen.getByText(/Kaboom!/));
1916+
expect(getHtml(container)).toMatchInlineSnapshot(`
1917+
"<div>
1918+
<p>
1919+
contextual error:
1920+
Kaboom!
1921+
</p>
1922+
</div>"
1923+
`);
1924+
});
1925+
1926+
it("handles fetcher.Form errors at the correct spot in the route hierarchy", async () => {
1927+
let { container } = render(
1928+
<TestDataRouter
1929+
window={getWindow("/child")}
1930+
hydrationData={{ loaderData: { "0": null } }}
1931+
>
1932+
<Route path="/" element={<Outlet />} errorElement={<p>Not I!</p>}>
1933+
<Route
1934+
path="child"
1935+
element={<Comp />}
1936+
errorElement={<ErrorElement />}
1937+
/>
1938+
<Route
1939+
path="fetch"
1940+
action={() => {
1941+
throw new Error("Kaboom!");
1942+
}}
1943+
errorElement={<p>Not I!</p>}
1944+
/>
1945+
</Route>
1946+
</TestDataRouter>
1947+
);
1948+
1949+
function Comp() {
1950+
let fetcher = useFetcher();
1951+
return (
1952+
<fetcher.Form method="post" action="/fetch">
1953+
<button type="submit" name="key" value="value">
1954+
submit
1955+
</button>
1956+
</fetcher.Form>
1957+
);
1958+
}
1959+
1960+
function ErrorElement() {
1961+
return <p>contextual error:{useRouteError().message}</p>;
1962+
}
1963+
1964+
expect(getHtml(container)).toMatchInlineSnapshot(`
1965+
"<div>
1966+
<form
1967+
action=\\"/fetch\\"
1968+
method=\\"post\\"
1969+
>
1970+
<button
1971+
name=\\"key\\"
1972+
type=\\"submit\\"
1973+
value=\\"value\\"
1974+
>
1975+
submit
1976+
</button>
1977+
</form>
1978+
</div>"
1979+
`);
1980+
1981+
fireEvent.click(screen.getByText("submit"));
1982+
await waitFor(() => screen.getByText(/Kaboom!/));
1983+
expect(getHtml(container)).toMatchInlineSnapshot(`
1984+
"<div>
1985+
<p>
1986+
contextual error:
1987+
Kaboom!
1988+
</p>
1989+
</div>"
1990+
`);
1991+
});
18101992
});
18111993

18121994
describe("errors", () => {

packages/react-router-dom/index.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
542542

543543
interface FormImplProps extends FormProps {
544544
fetcherKey?: string;
545+
routeId?: string;
545546
}
546547

547548
const FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
@@ -552,11 +553,12 @@ const FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
552553
action = ".",
553554
onSubmit,
554555
fetcherKey,
556+
routeId,
555557
...props
556558
},
557559
forwardedRef
558560
) => {
559-
let submit = useSubmitImpl(fetcherKey);
561+
let submit = useSubmitImpl(fetcherKey, routeId);
560562
let formMethod: FormMethod =
561563
method.toLowerCase() === "get" ? "get" : "post";
562564
let formAction = useFormAction(action);
@@ -745,7 +747,7 @@ export function useSubmit(): SubmitFunction {
745747
return useSubmitImpl();
746748
}
747749

748-
function useSubmitImpl(fetcherKey?: string): SubmitFunction {
750+
function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction {
749751
let router = React.useContext(UNSAFE_DataRouterContext);
750752
let defaultAction = useFormAction();
751753

@@ -780,12 +782,13 @@ function useSubmitImpl(fetcherKey?: string): SubmitFunction {
780782
formEncType: encType as FormEncType,
781783
};
782784
if (fetcherKey) {
783-
router.fetch(fetcherKey, href, opts);
785+
invariant(routeId != null, "No routeId available for useFetcher()");
786+
router.fetch(fetcherKey, routeId, href, opts);
784787
} else {
785788
router.navigate(href, opts);
786789
}
787790
},
788-
[defaultAction, router, fetcherKey]
791+
[defaultAction, router, fetcherKey, routeId]
789792
);
790793
}
791794

@@ -803,10 +806,17 @@ export function useFormAction(action = "."): string {
803806
return pathname + search;
804807
}
805808

806-
function createFetcherForm(fetcherKey: string) {
809+
function createFetcherForm(fetcherKey: string, routeId: string) {
807810
let FetcherForm = React.forwardRef<HTMLFormElement, FormProps>(
808811
(props, ref) => {
809-
return <FormImpl {...props} ref={ref} fetcherKey={fetcherKey} />;
812+
return (
813+
<FormImpl
814+
{...props}
815+
ref={ref}
816+
fetcherKey={fetcherKey}
817+
routeId={routeId}
818+
/>
819+
);
810820
}
811821
);
812822
if (__DEV__) {
@@ -831,13 +841,26 @@ export function useFetcher<TData = any>(): FetcherWithComponents<TData> {
831841
let router = React.useContext(UNSAFE_DataRouterContext);
832842
invariant(router, `useFetcher must be used within a DataRouter`);
833843

844+
let route = React.useContext(UNSAFE_RouteContext);
845+
invariant(route, `useFetcher must be used inside a RouteContext`);
846+
847+
let routeId = route.matches[route.matches.length - 1]?.route.id;
848+
invariant(
849+
routeId != null,
850+
`useFetcher can only be used on routes that contain a unique "id"`
851+
);
852+
834853
let [fetcherKey] = React.useState(() => String(++fetcherId));
835-
let [Form] = React.useState(() => createFetcherForm(fetcherKey));
854+
let [Form] = React.useState(() => {
855+
invariant(routeId, `No routeId available for fetcher.Form()`);
856+
return createFetcherForm(fetcherKey, routeId);
857+
});
836858
let [load] = React.useState(() => (href: string) => {
837-
invariant(router, `No router available for fetcher.load()`);
838-
router.fetch(fetcherKey, href);
859+
invariant(router, "No router available for fetcher.load()");
860+
invariant(routeId, "No routeId available for fetcher.load()");
861+
router.fetch(fetcherKey, routeId, href);
839862
});
840-
let submit = useSubmitImpl(fetcherKey);
863+
let submit = useSubmitImpl(fetcherKey, routeId);
841864

842865
let fetcher = router.getFetcher<TData>(fetcherKey);
843866

@@ -857,7 +880,7 @@ export function useFetcher<TData = any>(): FetcherWithComponents<TData> {
857880
// fetcher is no longer around.
858881
return () => {
859882
if (!router) {
860-
console.warn("No fetcher available to clean up from useFetcher()");
883+
console.warn(`No fetcher available to clean up from useFetcher()`);
861884
return;
862885
}
863886
router.deleteFetcher(fetcherKey);

packages/router/__tests__/router-test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ function setup({
580580
}
581581

582582
let helpers = getFetcherHelpers(key, href, navigationId, opts);
583-
currentRouter.fetch(key, href, opts);
583+
currentRouter.fetch(key, enhancedRoutes[0].id, href, opts);
584584
return helpers;
585585
}
586586

@@ -1497,7 +1497,7 @@ describe("a router", () => {
14971497
await tick();
14981498

14991499
let key = "key";
1500-
router.fetch(key, "/fetch");
1500+
router.fetch(key, "root", "/fetch");
15011501
await tick();
15021502
expect(router.state.fetchers.get(key)).toMatchObject({
15031503
state: "idle",
@@ -1574,7 +1574,7 @@ describe("a router", () => {
15741574
await tick();
15751575

15761576
let key = "key";
1577-
router.fetch(key, "/fetch", {
1577+
router.fetch(key, "root", "/fetch", {
15781578
formMethod: "post",
15791579
formData: createFormData({ key: "value" }),
15801580
});
@@ -4950,7 +4950,7 @@ describe("a router", () => {
49504950
});
49514951

49524952
let key = "key";
4953-
router.fetch(key, "/");
4953+
router.fetch(key, "root", "/");
49544954
expect(router.state.fetchers.get(key)).toEqual({
49554955
state: "loading",
49564956
formMethod: undefined,
@@ -6174,7 +6174,7 @@ describe("a router", () => {
61746174
expect(router.getFetcher(key)).toBe(IDLE_FETCHER);
61756175

61766176
// Fetch from a different route
6177-
router.fetch(key, "/fetch");
6177+
router.fetch(key, "root", "/fetch");
61786178
await tick();
61796179
expect(router.getFetcher(key)).toMatchObject({
61806180
state: "idle",

0 commit comments

Comments
 (0)