From c2c27d8b52d8ade43d30a1057a4caa687eecb309 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 7 Nov 2025 11:13:58 -0500 Subject: [PATCH] Fix bug with fetcher submission ancestor-thrown middleware errors --- .changeset/forty-bugs-turn.md | 5 ++ .../router/context-middleware-test.tsx | 50 +++++++++++++++++++ packages/react-router/lib/router/router.ts | 11 ++++ 3 files changed, 66 insertions(+) create mode 100644 .changeset/forty-bugs-turn.md diff --git a/.changeset/forty-bugs-turn.md b/.changeset/forty-bugs-turn.md new file mode 100644 index 0000000000..8f5e60db7d --- /dev/null +++ b/.changeset/forty-bugs-turn.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Properly handle ancestor thrown middleware errors before `next()` on fetcher submissions diff --git a/packages/react-router/__tests__/router/context-middleware-test.tsx b/packages/react-router/__tests__/router/context-middleware-test.tsx index ab62e5c132..8475937dc1 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.tsx +++ b/packages/react-router/__tests__/router/context-middleware-test.tsx @@ -1633,6 +1633,56 @@ describe("context/middleware", () => { e: new Error("E ERROR"), }); }); + + it("throwing from a fetcher action middleware before next bubbles up to the boundary", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "/a", + hasErrorBoundary: true, + children: [ + { + id: "b", + path: "b", + hasErrorBoundary: false, + middleware: [ + ({ request }) => { + if (request.method === "POST") { + throw new Error("B ERROR"); + } + }, + ], + action: () => "B", + }, + ], + }, + ], + }); + + // Bubbles to B because it's the initial load and it's loader hasn't run + await router.navigate("/a/b"); + expect(router.state.loaderData).toEqual({}); + expect(router.state.errors).toBeNull(); + + let data; + router.subscribe((state) => { + data ??= state.fetchers.get("key")?.data; + }); + + await router.fetch("key", "b", "/a/b", { + formMethod: "post", + formData: createFormData({}), + }); + expect(data).toBeUndefined(); + expect(router.state.errors).toEqual({ + a: new Error("B ERROR"), + }); + }); }); }); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 5719828ffc..50c79e0090 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2465,6 +2465,17 @@ export function createRouter(init: RouterInit): Router { ); let actionResult = actionResults[match.route.id]; + if (!actionResult) { + // If this error came from a parent middleware before the action ran, + // then it won't be tied to the action route + for (let match of fetchMatches) { + if (actionResults[match.route.id]) { + actionResult = actionResults[match.route.id]; + break; + } + } + } + if (fetchRequest.signal.aborted) { // We can delete this so long as we weren't aborted by our own fetcher // re-submit which would have put _new_ controller is in fetchControllers