Skip to content

Fix cleared loaderData bug on thrown action errors #13476

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-laws-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Fix bug where bubbled action errors would result in `loaderData` being cleared at the handling `ErrorBoundary` route
62 changes: 62 additions & 0 deletions packages/react-router/__tests__/router/should-revalidate-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,4 +1170,66 @@ describe("shouldRevalidate", () => {

router.dispose();
});

it("preserves ancestor loaderData on bubbled action errors when not revalidating with a custom data strategy", async () => {
let history = createMemoryHistory();
let router = createRouter({
history,
routes: [
{
id: "root",
path: "/",
hasErrorBoundary: true,
loader: () => "NOPE",
children: [
{
id: "index",
index: true,
action: () => {
throw new Response("ERROR 400", { status: 400 });
},
},
],
},
],
hydrationData: {
loaderData: {
root: "ROOT",
},
},
async dataStrategy({ request, matches }) {
let keyedResults = {};
let matchesToLoad = matches.filter((match) =>
match.unstable_shouldCallHandler(
request.method === "POST"
? undefined
: !match.unstable_shouldRevalidateArgs?.actionStatus ||
match.unstable_shouldRevalidateArgs.actionStatus < 400
)
);
await Promise.all(
matchesToLoad.map(async (match) => {
keyedResults[match.route.id] = await match.resolve();
})
);
return keyedResults;
},
});
router.initialize();

router.navigate("/?index", {
formMethod: "post",
formData: createFormData({ gosh: "dang" }),
});
await tick();
expect(router.state).toMatchObject({
location: { pathname: "/" },
navigation: { state: "idle" },
loaderData: { root: "ROOT" },
actionData: null,
errors: { root: new ErrorResponseImpl(400, "", "ERROR 400") },
});

router.dispose();
});
});
24 changes: 19 additions & 5 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,15 @@ interface ShortCircuitable {
shortCircuited?: boolean;
}

type PendingActionResult = [string, SuccessResult | ErrorResult];
// Track any pending errors from the action (or other pre-loader flows).
// The format is [bubbledRouteId, result, actionRouteId?]
// We should probably change this to an object now that we (optionally) track
// the original action route id so we can clear out loaderData in the right in
// processRouteLoaderData
//
type PendingActionResult =
| [string, SuccessResult | ErrorResult]
| [string, SuccessResult | ErrorResult, string];

interface HandleActionResult extends ShortCircuitable {
/**
Expand Down Expand Up @@ -1858,7 +1866,11 @@ export function createRouter(init: RouterInit): Router {

return {
matches,
pendingActionResult: [boundaryMatch.route.id, result],
pendingActionResult: [
boundaryMatch.route.id,
result,
actionMatch.route.id,
],
};
}

Expand Down Expand Up @@ -6047,11 +6059,13 @@ function processRouteLoaderData(
});

// If we didn't consume the pending action error (i.e., all loaders
// resolved), then consume it here. Also clear out any loaderData for the
// throwing route
// resolved), then consume it here
if (pendingError !== undefined && pendingActionResult) {
errors = { [pendingActionResult[0]]: pendingError };
loaderData[pendingActionResult[0]] = undefined;
// Clear out any loaderData for the throwing route
if (pendingActionResult[2]) {
loaderData[pendingActionResult[2]] = undefined;
}
}

return {
Expand Down