Repository navigation
unstable_navigationType reports "REPLACE" for loaders after an action error, while the navigation commits as "PUSH" #15618
Description
What happens
unstable_navigationType (added in #15586, dee9ad2) is documented as "the history action ("PUSH", "REPLACE", or "POP") for a navigation-initiated execution, including fetchers reloaded by that navigation".
A submission to the current location without an explicit replace starts as REPLACE (navigate() in router.ts). If the action throws, handleAction switches the committed action to PUSH so the user can go back to the form:
if ((opts && opts.replace) !== true) {
pendingAction = NavigationType.Push;
}startNavigation keeps passing its original historyAction (still REPLACE) into handleLoaders, which hands it to callLoadersAndMaybeResolveData(..., historyAction). So the post-action loader run, its client middleware, and any fetchers reloaded with it see unstable_navigationType: "REPLACE", and completeNavigation then calls history.push and sets state.historyAction to "PUSH".
The same stale value also lands on state.navigation.historyAction of the loading navigation built by getLoadingNavigation(location, matches, historyAction, opts.submission) after the action.
Minimal repro (in packages/react-router/__tests__/router/data-strategy-test.ts)
it("reports PUSH to loaders after an action error on a submission to the current location", async () => {
let seen: Array<string | null> = [];
let t = setup({
routes: [
{
id: "root",
path: "/",
loader: true,
children: [{ id: "page", path: "page", loader: true, action: true }],
},
],
initialEntries: ["/page"],
hydrationData: { loaderData: { root: "ROOT", page: "PAGE" } },
dataStrategy: async ({ matches, unstable_navigationType, request }) => {
if (request.method === "GET") seen.push(unstable_navigationType);
return Object.fromEntries(
matches
.filter((m) => m.shouldCallHandler())
.map((m) =>
request.method === "POST"
? [m.route.id, { type: "error", result: new Error("nope") }]
: [m.route.id, { type: "data", result: m.route.id }],
),
);
},
});
await t.router.navigate("/page", {
formMethod: "post",
formData: createFormData({}),
});
expect(t.router.state.historyAction).toBe("PUSH"); // passes
expect(seen).toEqual(["PUSH"]); // fails: ["REPLACE"]
});Expected
Every data strategy call that belongs to the navigation reports the action the navigation commits with: "PUSH" here, matching router.state.historyAction and the history entry that is written.
Actual
The loaders, their middleware and reloaded fetchers receive "REPLACE"; the navigation commits as "PUSH".
Suggested fix
In startNavigation, after handleAction returns (and before getLoadingNavigation and handleLoaders), take the action handleAction settled on:
matches = actionResult.matches || matches;
pendingActionResult = actionResult.pendingActionResult;
historyAction = pendingAction; // handleAction may have switched REPLACE to PUSH on an action error
loadingNavigation = getLoadingNavigation(location, matches, historyAction, opts.submission);Alternatively, handleAction could return the settled action alongside pendingActionResult, so the local value does not depend on the shared pendingAction variable. Either way the loading navigation's historyAction and unstable_navigationType then agree with the committed navigation. A test like the one above covers it.
Note: I reproduced this on a port of the router whose logic is identical to main at dee9ad2; I have not run this exact test inside this repository's own test setup.
Activity