Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a race condition in fetcher state machine where `formData` briefly became `undefined` before new `loaderData` was available, causing a UI flicker in optimistic update patterns (#14506)
Comment thread
brophdawg11 marked this conversation as resolved.
Outdated
Comment thread
brophdawg11 marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Memoize `useFetchers` to return a stable identity and only change if fetchers changed
111 changes: 108 additions & 3 deletions packages/react-router/__tests__/dom/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5738,6 +5738,111 @@ function testDomRouter(
`);
});

it("useFetchers returns stable array reference when fetchers are unchanged", async () => {
let fetchDfd = createDeferred();
let fetchersArrays: (Fetcher & { key: string })[][] = [];
let setCountRef = { current: null as React.Dispatch<React.SetStateAction<number>> | null };

function Parent() {
let fetchers = useFetchers();
let fetcher = useFetcher();
let [, setCount] = React.useState(0);
setCountRef.current = setCount;
fetchersArrays.push(fetchers);
return (
<button onClick={() => fetcher.load("/fetch")}>load</button>
);
}

let router = createTestRouter(
[
{
path: "/",
Component: Parent,
children: [{ path: "/fetch", loader: () => fetchDfd.promise }],
},
],
{
window: getWindow("/"),
hydrationData: { loaderData: { "0": null } },
},
);
render(<RouterProvider router={router} />);

// Wait for initial mount
await waitFor(() => screen.getByText("load"));
expect(fetchersArrays.at(-1)).toEqual([]);

// Trigger a fetch — fetchers array should have a loading fetcher
fireEvent.click(screen.getByText("load"));
await waitFor(() =>
expect(fetchersArrays.at(-1)).toEqual(
expect.arrayContaining([
expect.objectContaining({ state: "loading" }),
]),
),
);
let arrayWhileLoading = fetchersArrays.at(-1)!;

// Unrelated state update re-renders the component — array ref should be stable
act(() => setCountRef.current!((c) => c + 1));
expect(fetchersArrays.at(-1)).toBe(arrayWhileLoading);

// Resolve the fetch — fetchers go idle and are removed from useFetchers
fetchDfd.resolve("DATA");
await waitFor(() => expect(fetchersArrays.at(-1)).toEqual([]));

// The final empty array is a new reference (fetchers changed)
expect(fetchersArrays.at(-1)).not.toBe(arrayWhileLoading);
});

it("useFetchers updates when a fetcher transitions state", async () => {
let fetchDfd = createDeferred();
let states: string[] = [];

function Parent() {
let fetchers = useFetchers();
let fetcher = useFetcher();
states.push(fetchers.map((f) => f.state).join(",") || "empty");
return (
<button onClick={() => fetcher.load("/fetch")}>load</button>
);
}

let router = createTestRouter(
[
{
path: "/",
Component: Parent,
children: [{ path: "/fetch", loader: () => fetchDfd.promise }],
},
],
{
window: getWindow("/"),
hydrationData: { loaderData: { "0": null } },
},
);
render(<RouterProvider router={router} />);

// Wait for initial mount
await waitFor(() => screen.getByText("load"));
expect(states.at(-1)).toBe("empty");

// Fetch starts — useFetchers should see "loading"
fireEvent.click(screen.getByText("load"));
await waitFor(() => expect(states).toContain("loading"));

// Fetch completes — useFetchers should go back to empty (idle fetchers excluded)
fetchDfd.resolve("DATA");
await waitFor(() => expect(states.at(-1)).toBe("empty"));

// States should have progressed: …empty → loading → empty
expect(states).toContain("loading");
let loadingIdx = states.lastIndexOf("loading");
let lastEmptyIdx = states.lastIndexOf("empty");
expect(lastEmptyIdx).toBeGreaterThan(loadingIdx);
});

it("handles revalidating fetchers", async () => {
let count = 0;
let fetchCount = 0;
Expand Down Expand Up @@ -6501,10 +6606,10 @@ function testDomRouter(
expect(container.querySelector("pre")?.innerHTML).toBe("");
fireEvent.click(screen.getByText("Load fetchers"));
await waitFor(() =>
// React `useId()` results in something such as `_r_2k_` or `_r_u_`
// depending on `DataBrowserRouter`/`DataHashRouter`
// React `useId()` results in something such as `_r_2k_`, `_r_u_`,
// or `_r_11_` depending on React version and component tree depth
expect(container.querySelector("pre")?.innerHTML).toMatch(
/^_r_[0-9]?[a-z]_,my-key$/,
/^_r_[0-9a-z]+_,my-key$/,
),
);
});
Expand Down
257 changes: 256 additions & 1 deletion packages/react-router/__tests__/router/fetchers-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable jest/valid-title */
import type { HydrationState } from "../../lib/router/router";
import type { Fetcher, HydrationState } from "../../lib/router/router";
import { createMemoryHistory } from "../../lib/router/history";
import {
createRouter,
Expand Down Expand Up @@ -3703,4 +3703,259 @@ describe("fetchers", () => {
expect(A.loaders.fetch.signal.reason).toBe("BECAUSE I SAID SO");
});
});

describe("fetcher Map mutation", () => {
// The root cause of the bug: after updateState({ fetchers: new Map(...) })
// hands a Map (MapA) to React, a subsequent direct mutation of
// state.fetchers (e.g. state.fetchers.set(key, getDoneFetcher())) mutates
// that same MapA because state.fetchers === MapA after the updateState.
// React's concurrent renderer may still hold MapA and render it post-
// mutation, seeing an idle fetcher (formData gone) alongside stale
// loaderData — the "flicker".
//
// The subscriber-based approach does NOT catch this: the subscriber is
// called synchronously after each updateState, so it only ever sees
// fully-settled state. Instead, the test captures the Map reference handed
// to the subscriber during the "loading" phase and, after the fetch
// completes, asserts that reference was never mutated in place.
it("does not mutate the Map reference handed to subscribers (fetcher.submit)", async () => {
let fetcherMaps: Map<string, Fetcher>[] = [];
let itemStatus = false;

let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/item"] }),
routes: [
{
id: "item",
path: "/item",
loader: () => ({ status: itemStatus }),
action: async ({ request }) => {
let formData = await request.formData();
itemStatus = formData.get("status") === "true";
return { ok: true };
},
},
],
hydrationData: {
loaderData: { item: { status: false } },
},
});

router.initialize();

// Mount the fetcher so it stays in state
router.getFetcher("toggle");

router.subscribe((state) => {
fetcherMaps.push(state.fetchers);
});

let formData = new FormData();
formData.append("status", "true");

await router.fetch("toggle", "item", "/item", {
formMethod: "POST",
formData,
});

router.dispose();

// After the fetch fully completes, the Maps we captured during the
// submitting/loading phases must still reflect submitting/loading — it must not have been
// mutated to "idle" in place
expect(fetcherMaps.length).toBe(3);
expect(fetcherMaps[0].get("toggle")?.state).toBe("submitting");
expect(fetcherMaps[0].get("toggle")?.formData).toBeDefined();
expect(fetcherMaps[1].get("toggle")?.state).toBe("loading");
expect(fetcherMaps[1].get("toggle")?.formData).toBeDefined();
expect(fetcherMaps[2].get("toggle")).toBeUndefined();
});

it("does not mutate the Map reference handed to subscribers (fetcher.load)", async () => {
// updateFetcherState() does: state.fetchers.set(key, fetcher); updateState({fetchers: new Map(state.fetchers)}).
// After the first call (loading state), state.fetchers === MapA which React holds.
// The second call (done state) mutates MapA via state.fetchers.set before creating MapB.
let fetcherMaps: Map<string, Fetcher>[] = [];

let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routes: [
{ id: "root", path: "/", loader: () => ({ data: "root" }) },
{ id: "item", path: "/item", loader: () => ({ data: "item" }) },
],
hydrationData: { loaderData: { root: { data: "root" } } },
});

router.initialize();
router.getFetcher("load");

router.subscribe((state) => {
fetcherMaps.push(state.fetchers);
});

await router.fetch("load", "root", "/item");

router.dispose();

// After the fetch fully completes, the Maps we captured during the
// loading phase must still reflect loading — they must not have been
// mutated to "idle" in place.
expect(fetcherMaps.length).toBe(2);
expect(fetcherMaps[0].get("load")?.state).toBe("loading");
expect(fetcherMaps[0].get("load")?.data).toBeUndefined();
expect(fetcherMaps[1].get("load")).toBeUndefined();
});

it("does not mutate the Map reference handed to subscribers (fetcher revalidation during navigation)", async () => {
// getUpdatedRevalidatingFetchers() (dev branch) calls state.fetchers.set()
// on the current Map before returning a copy. This mutates MapPrev.
// Later, processLoaderData mutates the Map that subscribers received for
// the "loading" revalidation state. Test that the subscriber's loading
// Map is not mutated to idle by processLoaderData after loaders complete.
let fetcherMaps: Map<string, Fetcher>[] = [];

let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routes: [
{
id: "root",
path: "/",
loader: () => ({ data: "root" }),
action: () => ({ ok: true }),
},
],
hydrationData: { loaderData: { root: { data: "root" } } },
});

router.initialize();
router.getFetcher("f");

router.subscribe((state) => {
fetcherMaps.push(state.fetchers);
});

// GET load to register the fetcher in fetchLoadMatches (eligible for
// revalidation when a subsequent navigation action fires).
await router.fetch("f", "root", "/");

// POST to the same route — sets isRevalidationRequired=true and causes
// fetcher "f" to revalidate alongside the route loaders.
let formData = new FormData();
await router.navigate("/", { formMethod: "POST", formData });

router.dispose();

// After the navigation fully completes, the Maps we captured during the
// loading phases (initial fetch + revalidation) must still reflect
// loading — they must not have been mutated to "idle"/done in place by
// getUpdatedRevalidatingFetchers/processLoaderData.
expect(fetcherMaps.length).toBe(5);
expect(fetcherMaps[0].get("f")?.state).toBe("loading");
expect(fetcherMaps[1].get("f")).toBeUndefined();
expect(fetcherMaps[2].get("f")).toBeUndefined();
expect(fetcherMaps[3].get("f")?.state).toBe("loading");
expect(fetcherMaps[4].get("f")).toBeUndefined();
});

it("does not mutate the Map reference handed to subscribers (fetcher loader redirect)", async () => {
// handleFetcherLoader hands MapA (loading) to React via updateFetcherState().
// When the loader returns a redirect, markFetchRedirectsDone() calls
// state.fetchers.set(key, doneFetcher) which mutates MapA before the
// final completeNavigation updateState creates MapB.
let fetcherMaps: Map<string, Fetcher>[] = [];

let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routes: [
{ id: "root", path: "/", loader: () => ({ data: "root" }) },
{
id: "redirect-source",
path: "/redirect",
loader: () =>
new Response(null, {
status: 302,
headers: { Location: "/" },
}),
},
],
hydrationData: { loaderData: { root: { data: "root" } } },
});

router.initialize();
router.getFetcher("redir");

router.subscribe((state) => {
fetcherMaps.push(state.fetchers);
});

await router.fetch("redir", "root", "/redirect");

router.dispose();

// After the redirect navigation fully completes, the Maps captured
// during the loading phases must still reflect loading — they must not
// have been mutated to "idle"/done in place by markFetchRedirectsDone().
expect(fetcherMaps.length).toBe(3);
expect(fetcherMaps[0].get("redir")?.state).toBe("loading");
expect(fetcherMaps[1].get("redir")?.state).toBe("loading");
expect(fetcherMaps[2].get("redir")).toBeUndefined();
});

it("does not mutate the Map reference handed to subscribers (fetcher action redirect)", async () => {
// When a fetcher action returns a redirect, handleFetcherAction calls
// updateFetcherState(key, getLoadingFetcher(submission)) which first
// mutates the "submitting" MapA via state.fetchers.set, creates MapB
// (loading), then kicks off startRedirectNavigation. Inside
// completeNavigation, markFetchRedirectsDone() mutates MapB
// (state.fetchers.set(key, doneFetcher)) before the final updateState
// creates MapC. Test that MapB is not mutated.
let fetcherMaps: Map<string, Fetcher>[] = [];

let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routes: [
{ id: "root", path: "/", loader: () => ({ data: "root" }) },
{
id: "action-route",
path: "/action",
action: () =>
new Response(null, {
status: 302,
headers: { Location: "/" },
}),
},
],
hydrationData: { loaderData: { root: { data: "root" } } },
});

router.initialize();
router.getFetcher("act");

router.subscribe((state) => {
fetcherMaps.push(state.fetchers);
});

let formData = new FormData();
await router.fetch("act", "root", "/action", {
formMethod: "POST",
formData,
});

router.dispose();

// After the redirect navigation fully completes, the Maps captured
// during the submitting/loading phases must still reflect those
// states (with formData intact) — they must not have been mutated to
// "idle"/done in place by markFetchRedirectsDone() before
// completeNavigation finalizes.
expect(fetcherMaps.length).toBe(4);
expect(fetcherMaps[0].get("act")?.state).toBe("submitting");
expect(fetcherMaps[0].get("act")?.formData).toBeDefined();
expect(fetcherMaps[1].get("act")?.state).toBe("loading");
expect(fetcherMaps[1].get("act")?.formData).toBeDefined();
expect(fetcherMaps[2].get("act")?.state).toBe("loading");
expect(fetcherMaps[2].get("act")?.formData).toBeDefined();
expect(fetcherMaps[3].get("act")).toBeUndefined();
});
});
});
Loading
Loading