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); }