diff --git a/.changeset/fetchers-concurrent-deferred-fix.md b/.changeset/fetchers-concurrent-deferred-fix.md
new file mode 100644
index 0000000000..938e9de3e9
--- /dev/null
+++ b/.changeset/fetchers-concurrent-deferred-fix.md
@@ -0,0 +1,7 @@
+---
+"@remix-run/react-router": patch
+"react-router": patch
+"react-router-dom": patch
+---
+
+Fixed an issue where multiple concurrent `useFetcher()` submissions would collapse into a single effective inflight fetcher on routes that use deferred loaders with Suspense/Await. Fetchers now maintain their independent states during deferred revalidation cycles, ensuring consistent behavior between blocking and deferred routes.
\ No newline at end of file
diff --git a/contributors.yml b/contributors.yml
index daa45d0f08..8359f96086 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -471,6 +471,7 @@
- yuleicul
- yuri-poliantsev
- zeevick10
+- Axadali
- zeromask1337
- zheng-chuang
- zxTomw
diff --git a/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx b/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx
new file mode 100644
index 0000000000..52ed439ed1
--- /dev/null
+++ b/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx
@@ -0,0 +1,373 @@
+/**
+ * Integration test for the useFetchers fix with deferred routes
+ * This test verifies that multiple concurrent fetchers work correctly on deferred routes
+ */
+
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import * as React from "react";
+import {
+ createMemoryRouter,
+ Outlet,
+ RouterProvider,
+ useFetcher,
+ useFetchers,
+ useLoaderData,
+} from "../../index";
+import { createDeferred, tick } from "./utils/utils";
+
+describe("useFetchers with deferred routes", () => {
+ beforeEach(() => {
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+ jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("should preserve concurrent fetcher behavior on deferred routes", async () => {
+ const pageDeferred = createDeferred();
+ const fetcherDeferreds = [createDeferred(), createDeferred(), createDeferred()];
+
+ function ParentLayout() {
+ return (
+
+
+
+ );
+ }
+
+ function DeferredRoute() {
+ const fetcher1 = useFetcher({ key: "fetcher-1" });
+ const fetcher2 = useFetcher({ key: "fetcher-2" });
+ const fetcher3 = useFetcher({ key: "fetcher-3" });
+ const fetchers = useFetchers();
+ const loaderData = useLoaderData() as { pageData: any };
+
+ return (
+
+
Page data: {loaderData?.pageData?.message || "loading"}
+
+
+
+
+
{fetchers.length}
+
+ {fetchers.map((f, i) => (
+
+ {f.state},
+
+ ))}
+
+
+ );
+ }
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ element: ,
+ hydrateFallbackElement: Loading...
,
+ children: [
+ {
+ index: true,
+ loader: async () => {
+ // Simulate deferred data using a promise
+ return {
+ pageData: await pageDeferred.promise,
+ };
+ },
+ Component: DeferredRoute,
+ },
+ ],
+ },
+ {
+ path: "/api/data1",
+ loader: async () => {
+ await fetcherDeferreds[0].promise;
+ return { data: "data1" };
+ },
+ },
+ {
+ path: "/api/data2",
+ loader: async () => {
+ await fetcherDeferreds[1].promise;
+ return { data: "data2" };
+ },
+ },
+ {
+ path: "/api/data3",
+ loader: async () => {
+ await fetcherDeferreds[2].promise;
+ return { data: "data3" };
+ },
+ },
+ ],
+ {
+ initialEntries: ["/"],
+ }
+ );
+
+ render();
+
+ // Initially should show loading fallback
+ await waitFor(() => screen.getByText("Loading..."));
+
+ // Resolve the page deferred promise to show the actual content
+ await act(async () => {
+ pageDeferred.resolve({ message: "resolved" });
+ await tick();
+ });
+
+ // Wait for content to render
+ await waitFor(() => screen.getByText("Fetch 1"));
+
+ // Initially all should be idle (no active fetchers)
+ expect(screen.getByText("0")).toBeInTheDocument();
+
+ // Submit multiple fetchers concurrently
+ await act(async () => {
+ fireEvent.click(screen.getByText("Fetch 1"));
+ fireEvent.click(screen.getByText("Fetch 2"));
+ fireEvent.click(screen.getByText("Fetch 3"));
+ await tick();
+ });
+
+ // Now we should have 3 fetchers in loading state
+ await waitFor(() => {
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ // Verify all are in loading state (use querySelector since there are multiple)
+ const loadingSpans = document.querySelectorAll("[id^='fetcher-'][id$='-state']");
+ expect(loadingSpans.length).toBe(3);
+ loadingSpans.forEach((span) => {
+ expect(span.textContent).toContain("loading");
+ });
+
+ // Resolve all fetcher deferreds one by one
+ await act(async () => {
+ fetcherDeferreds[0].resolve({ data: "data1" });
+ await tick();
+ });
+
+ // Should still have 2 active fetchers
+ await waitFor(() => {
+ expect(screen.getByText("2")).toBeInTheDocument();
+ });
+
+ await act(async () => {
+ fetcherDeferreds[1].resolve({ data: "data2" });
+ await tick();
+ });
+
+ // Should still have 1 active fetcher
+ await waitFor(() => {
+ expect(screen.getByText("1")).toBeInTheDocument();
+ });
+
+ await act(async () => {
+ fetcherDeferreds[2].resolve({ data: "data3" });
+ await tick();
+ });
+
+ // All fetchers completed - should be 0 active fetchers
+ await waitFor(() => {
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+ });
+
+ it("should maintain independent fetcher states during deferred revalidation", async () => {
+ const pageDeferred = createDeferred();
+ const fetcherDeferreds = Array.from({ length: 5 }, () => createDeferred());
+
+ function ParentLayout() {
+ return (
+
+
+
+ );
+ }
+
+ function DeferredRoute() {
+ const fetchers = useFetchers();
+ const loaderData = useLoaderData() as { slowData: any };
+
+ // Create 5 independent fetchers with unique keys
+ const fetcher0 = useFetcher({ key: "fetcher-0" });
+ const fetcher1 = useFetcher({ key: "fetcher-1" });
+ const fetcher2 = useFetcher({ key: "fetcher-2" });
+ const fetcher3 = useFetcher({ key: "fetcher-3" });
+ const fetcher4 = useFetcher({ key: "fetcher-4" });
+
+ return (
+
+
Slow data: {loaderData?.slowData?.message || "loading"}
+ {/* Multiple fetcher buttons to test concurrency */}
+
+
+
+
+
+
+
{fetchers.length}
+
+ {fetchers.map((f, idx) => (
+
+ {f.state}-
+
+ ))}
+
+
+ );
+ }
+
+ const router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ element: ,
+ hydrateFallbackElement: Loading...
,
+ children: [
+ {
+ index: true,
+ loader: async () => {
+ // Deferred loader that takes some time
+ return {
+ slowData: await pageDeferred.promise,
+ };
+ },
+ Component: DeferredRoute,
+ },
+ ],
+ },
+ {
+ path: "/api/data0",
+ loader: async () => {
+ await fetcherDeferreds[0].promise;
+ return { data: "data0" };
+ },
+ },
+ {
+ path: "/api/data1",
+ loader: async () => {
+ await fetcherDeferreds[1].promise;
+ return { data: "data1" };
+ },
+ },
+ {
+ path: "/api/data2",
+ loader: async () => {
+ await fetcherDeferreds[2].promise;
+ return { data: "data2" };
+ },
+ },
+ {
+ path: "/api/data3",
+ loader: async () => {
+ await fetcherDeferreds[3].promise;
+ return { data: "data3" };
+ },
+ },
+ {
+ path: "/api/data4",
+ loader: async () => {
+ await fetcherDeferreds[4].promise;
+ return { data: "data4" };
+ },
+ },
+ ],
+ {
+ initialEntries: ["/"],
+ }
+ );
+
+ render();
+
+ // Initially should show loading fallback
+ await waitFor(() => screen.getByText("Loading..."));
+
+ // Resolve the page deferred loader
+ await act(async () => {
+ pageDeferred.resolve({ message: "deferred resolved" });
+ await tick();
+ });
+
+ // Wait for content to render
+ await waitFor(() => screen.getByText("Fetch 1"));
+
+ // Submit multiple fetchers rapidly to test concurrency
+ await act(async () => {
+ fireEvent.click(screen.getByText("Fetch 1"));
+ fireEvent.click(screen.getByText("Fetch 2"));
+ fireEvent.click(screen.getByText("Fetch 3"));
+ fireEvent.click(screen.getByText("Fetch 4"));
+ fireEvent.click(screen.getByText("Fetch 5"));
+ await tick();
+ });
+
+ // All 5 fetchers should be present and in loading state
+ await waitFor(() => {
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+
+ // Verify loading states
+ const loadingSpans = document.querySelectorAll("[id^='fetcher-'][id$='-state-display']");
+ expect(loadingSpans.length).toBe(5);
+ loadingSpans.forEach((span) => {
+ expect(span.textContent).toContain("loading");
+ });
+
+ // Resolve all fetcher deferreds
+ await act(async () => {
+ fetcherDeferreds.forEach((dfd) => dfd.resolve({ data: "done" }));
+ await tick();
+ });
+
+ // All fetchers completed - should be 0 active fetchers
+ await waitFor(() => {
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index 34c74cc28d..1f16dc4510 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -2325,11 +2325,15 @@ export function createRouter(init: RouterInit): Router {
) {
revalidatingFetchers.forEach((rf) => {
let fetcher = state.fetchers.get(rf.key);
- let revalidatingFetcher = getLoadingFetcher(
- undefined,
- fetcher ? fetcher.data : undefined,
- );
- state.fetchers.set(rf.key, revalidatingFetcher);
+ // Only update fetchers that are not already in a submitting or loading state
+ // This preserves concurrent fetchers that were submitted independently
+ if (!fetcher || (fetcher.state !== "submitting" && fetcher.state !== "loading")) {
+ let revalidatingFetcher = getLoadingFetcher(
+ undefined,
+ fetcher ? fetcher.data : undefined,
+ );
+ state.fetchers.set(rf.key, revalidatingFetcher);
+ }
});
return new Map(state.fetchers);
}