From e353e1605b8a7b8efe5fbf6ea9219485f41ff957 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Mon, 16 Feb 2026 11:55:19 +0500 Subject: [PATCH 1/6] Fix issue: useFetchers breaking on deferred routes: parallel optimistic rendering not possible with Suspense/Await #14768 --- .../router/useFetchers-deferred-test.ts | 173 ++++++++++++++++++ packages/react-router/lib/router/router.ts | 15 +- 2 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 packages/react-router/__tests__/router/useFetchers-deferred-test.ts diff --git a/packages/react-router/__tests__/router/useFetchers-deferred-test.ts b/packages/react-router/__tests__/router/useFetchers-deferred-test.ts new file mode 100644 index 0000000000..837cfc4792 --- /dev/null +++ b/packages/react-router/__tests__/router/useFetchers-deferred-test.ts @@ -0,0 +1,173 @@ +/** + * Integration test for the useFetchers fix with deferred routes + * This test verifies that multiple concurrent fetchers work correctly on deferred routes + */ + +import { + createMemoryRouter, + defer, + useFetcher, + useFetchers, +} from "../index"; +import { renderWithRouter, act } from "./utils/test-renderer"; + +describe("useFetchers with deferred routes", () => { + it("should preserve concurrent fetcher behavior on deferred routes", async () => { + let resolveDeferred: (value: any) => void; + const deferredPromise = new Promise((resolve) => { + resolveDeferred = resolve; + }); + + function DeferredRoute() { + const fetcher = useFetcher(); + const fetchers = useFetchers(); + + return ( +
+ + + + +
{fetchers.length}
+
+ {fetchers.map((f, i) => ( + + {f.state}, + + ))} +
+
+ ); + } + + const router = createMemoryRouter([ + { + path: "/", + loader: async () => { + // Simulate deferred data + return defer({ + deferredData: await deferredPromise, + }); + }, + Component: DeferredRoute, + }, + ]); + + let { container } = renderWithRouter(router); + + // Initially all should be idle + expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("0"); + + // Submit multiple fetchers concurrently + await act(async () => { + document.getElementById("fetcher-btn-1")?.click(); + document.getElementById("fetcher-btn-2")?.click(); + document.getElementById("fetcher-btn-3")?.click(); + }); + + // Now we should have 3 fetchers + expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("3"); + + // Initially they should be in loading state + const fetcherStates = container.querySelectorAll("[id^='fetcher-']"); + expect(fetcherStates.length).toBe(3); + + // Resolve the deferred promise + await act(async () => { + resolveDeferred!({ message: "resolved" }); + await new Promise((resolve) => setTimeout(resolve, 10)); // Let the state update + }); + + // All fetchers should still be present and eventually become idle + expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("3"); + }); + + it("should maintain independent fetcher states during deferred revalidation", async () => { + let resolveDeferred: (value: any) => void; + const deferredPromise = new Promise((resolve) => { + resolveDeferred = resolve; + }); + + function DeferredRoute() { + const fetcher = useFetcher(); + const fetchers = useFetchers(); + + return ( +
+ {/* Multiple fetcher buttons to test concurrency */} + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + +
{fetchers.length}
+
+ {fetchers.map((f, idx) => ( + + {f.state}- + + ))} +
+
+ ); + } + + const router = createMemoryRouter([ + { + path: "/", + loader: async () => { + // Deferred loader that takes some time + return defer({ + slowData: await deferredPromise, + }); + }, + Component: DeferredRoute, + }, + ]); + + let { container } = renderWithRouter(router); + + // Submit multiple fetchers rapidly to test concurrency + await act(async () => { + for (let i = 0; i < 5; i++) { + document.getElementById(`fetcher-btn-${i}`)?.click(); + } + }); + + // All 5 fetchers should be present + expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("5"); + + // Resolve the deferred loader + await act(async () => { + resolveDeferred!({ message: "deferred resolved" }); + await new Promise((resolve) => setTimeout(resolve, 20)); // Allow state updates + }); + + // All fetchers should still be present after deferred resolution + expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("5"); + + // All fetchers should eventually complete independently + const stateDisplays = container.querySelectorAll("[id^='fetcher-'][id$='-state-display']"); + expect(stateDisplays.length).toBe(5); + }); +}); \ 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..65b92b55ed 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2325,11 +2325,16 @@ 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); + } + // If the fetcher is already in submitting/loading state, leave it as is to preserve concurrency }); return new Map(state.fetchers); } From c33d5908b49e1d0863915230715387bacaa260e5 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Mon, 16 Feb 2026 11:57:42 +0500 Subject: [PATCH 2/6] add changeset file --- .changeset/fetchers-concurrent-deferred-fix.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fetchers-concurrent-deferred-fix.md 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 From 582c7634f28215d8bcce3ef5272b58d8e76dfae1 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Mon, 16 Feb 2026 14:02:47 +0500 Subject: [PATCH 3/6] remove extra comment --- packages/react-router/lib/router/router.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 65b92b55ed..1f16dc4510 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2334,7 +2334,6 @@ export function createRouter(init: RouterInit): Router { ); state.fetchers.set(rf.key, revalidatingFetcher); } - // If the fetcher is already in submitting/loading state, leave it as is to preserve concurrency }); return new Map(state.fetchers); } From f1bdf11883c63dece4a7c74031b94ca40adc040a Mon Sep 17 00:00:00 2001 From: Asad Ali <46473237+Axadali@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:11:11 +0500 Subject: [PATCH 4/6] Add Axadali to contributors list --- contributors.yml | 1 + 1 file changed, 1 insertion(+) 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 From 85830fbc121ffe7cde4207a74508ddc2abd162d9 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Tue, 17 Feb 2026 12:31:41 +0500 Subject: [PATCH 5/6] fix linting issue --- ...useFetchers-deferred-test.ts => useFetchers-deferred-test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-router/__tests__/router/{useFetchers-deferred-test.ts => useFetchers-deferred-test.tsx} (100%) diff --git a/packages/react-router/__tests__/router/useFetchers-deferred-test.ts b/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx similarity index 100% rename from packages/react-router/__tests__/router/useFetchers-deferred-test.ts rename to packages/react-router/__tests__/router/useFetchers-deferred-test.tsx From f573a9c22772ec788dbffecfc95535ef8ca691cd Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Wed, 18 Feb 2026 12:23:44 +0500 Subject: [PATCH 6/6] Fix testcase issue --- .../router/useFetchers-deferred-test.tsx | 360 ++++++++++++++---- 1 file changed, 280 insertions(+), 80 deletions(-) diff --git a/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx b/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx index 837cfc4792..52ed439ed1 100644 --- a/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx +++ b/packages/react-router/__tests__/router/useFetchers-deferred-test.tsx @@ -3,46 +3,69 @@ * 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, - defer, + Outlet, + RouterProvider, useFetcher, useFetchers, -} from "../index"; -import { renderWithRouter, act } from "./utils/test-renderer"; + 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 () => { - let resolveDeferred: (value: any) => void; - const deferredPromise = new Promise((resolve) => { - resolveDeferred = resolve; - }); + const pageDeferred = createDeferred(); + const fetcherDeferreds = [createDeferred(), createDeferred(), createDeferred()]; + + function ParentLayout() { + return ( +
+ +
+ ); + } function DeferredRoute() { - const fetcher = useFetcher(); + 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) => ( @@ -55,71 +78,179 @@ describe("useFetchers with deferred routes", () => { ); } - const router = createMemoryRouter([ - { - path: "/", - loader: async () => { - // Simulate deferred data - return defer({ - deferredData: await deferredPromise, - }); + 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" }; + }, }, - Component: DeferredRoute, - }, - ]); + ], + { + initialEntries: ["/"], + } + ); + + render(); - let { container } = renderWithRouter(router); + // 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(); + }); - // Initially all should be idle - expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("0"); + // 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 () => { - document.getElementById("fetcher-btn-1")?.click(); - document.getElementById("fetcher-btn-2")?.click(); - document.getElementById("fetcher-btn-3")?.click(); + 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 - expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("3"); + // Now we should have 3 fetchers in loading state + await waitFor(() => { + expect(screen.getByText("3")).toBeInTheDocument(); + }); - // Initially they should be in loading state - const fetcherStates = container.querySelectorAll("[id^='fetcher-']"); - expect(fetcherStates.length).toBe(3); + // 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 the deferred promise + // Resolve all fetcher deferreds one by one await act(async () => { - resolveDeferred!({ message: "resolved" }); - await new Promise((resolve) => setTimeout(resolve, 10)); // Let the state update + fetcherDeferreds[0].resolve({ data: "data1" }); + await tick(); }); - // All fetchers should still be present and eventually become idle - expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("3"); + // 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 () => { - let resolveDeferred: (value: any) => void; - const deferredPromise = new Promise((resolve) => { - resolveDeferred = resolve; - }); + const pageDeferred = createDeferred(); + const fetcherDeferreds = Array.from({ length: 5 }, () => createDeferred()); + + function ParentLayout() { + return ( +
+ +
+ ); + } function DeferredRoute() { - const fetcher = useFetcher(); 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 */} - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - + + + + + +
{fetchers.length}
{fetchers.map((f, idx) => ( @@ -132,42 +263,111 @@ describe("useFetchers with deferred routes", () => { ); } - const router = createMemoryRouter([ - { - path: "/", - loader: async () => { - // Deferred loader that takes some time - return defer({ - slowData: await deferredPromise, - }); + 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" }; + }, }, - Component: DeferredRoute, - }, - ]); + { + 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: ["/"], + } + ); - let { container } = renderWithRouter(router); + render(); - // Submit multiple fetchers rapidly to test concurrency + // Initially should show loading fallback + await waitFor(() => screen.getByText("Loading...")); + + // Resolve the page deferred loader await act(async () => { - for (let i = 0; i < 5; i++) { - document.getElementById(`fetcher-btn-${i}`)?.click(); - } + pageDeferred.resolve({ message: "deferred resolved" }); + await tick(); }); - // All 5 fetchers should be present - expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("5"); + // Wait for content to render + await waitFor(() => screen.getByText("Fetch 1")); - // Resolve the deferred loader + // Submit multiple fetchers rapidly to test concurrency await act(async () => { - resolveDeferred!({ message: "deferred resolved" }); - await new Promise((resolve) => setTimeout(resolve, 20)); // Allow state updates + 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(); }); - // All fetchers should still be present after deferred resolution - expect(container.querySelector("#fetcher-state-count")?.textContent).toBe("5"); + // 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"); + }); - // All fetchers should eventually complete independently - const stateDisplays = container.querySelectorAll("[id^='fetcher-'][id$='-state-display']"); - expect(stateDisplays.length).toBe(5); + // 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