From c0881a94fd3421103062bd330ef6e6c67a0fe2ab Mon Sep 17 00:00:00 2001 From: Sarthak Rawat Date: Sun, 30 Nov 2025 14:27:18 +0530 Subject: [PATCH 1/3] fix: prevent fetcher formData clearing before loaderData updates (#14506) --- packages/react-router/lib/router/router.ts | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index da9d2557fe..764ce4abc7 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1363,6 +1363,19 @@ export function createRouter(init: RouterInit): Router { ) : state.loaderData; + // Transition any fetchers that were kept in loading state (with formData) to idle + // now that we're committing the loaderData. This ensures the fetcher state change + // and loaderData update happen atomically in the same updateState() call. + let fetchers = newState.fetchers ? new Map(newState.fetchers) : new Map(state.fetchers); + let updatedFetchers = false; + fetchers.forEach((fetcher, key) => { + if (fetcher.state === "loading" && fetcher.formData) { + // Transition to idle now that loaderData is being committed + fetchers.set(key, getDoneFetcher(fetcher.data)); + updatedFetchers = true; + } + }); + // On a successful navigation we can assume we got through all blockers // so we can start fresh let blockers = state.blockers; @@ -1436,7 +1449,7 @@ export function createRouter(init: RouterInit): Router { updateState( { - ...newState, // matches, errors, fetchers go through as-is + ...newState, // matches, errors go through as-is actionData, loaderData, historyAction: pendingAction, @@ -1447,6 +1460,8 @@ export function createRouter(init: RouterInit): Router { restoreScrollPosition, preventScrollReset, blockers, + // Use updated fetchers if we transitioned any from loading to idle + ...(updatedFetchers ? { fetchers } : {}), }, { viewTransitionOpts, @@ -6549,8 +6564,22 @@ function processLoaderData( // keep this to type narrow to a success result in the else invariant(false, "Unhandled fetcher revalidation redirect"); } else { + // Get the current fetcher state to check if it has formData + let existingFetcher = state.fetchers.get(key); let doneFetcher = getDoneFetcher(result.data); - state.fetchers.set(key, doneFetcher); + + // If the fetcher currently has formData, keep it + // in loading state with the new data until completeNavigation commits both + // the fetcher state and loaderData together. This prevents a flicker where + // fetcher.formData becomes undefined before new loaderData is available. + if (existingFetcher && existingFetcher.formData) { + state.fetchers.set(key, { + ...existingFetcher, + data: result.data, + }); + } else { + state.fetchers.set(key, doneFetcher); + } } }); From f02ec0bda3d692d0d989a3f0424874335cbf64da Mon Sep 17 00:00:00 2001 From: Sarthak Rawat Date: Sun, 30 Nov 2025 14:51:26 +0530 Subject: [PATCH 2/3] Added username to contributors.yml --- contributors.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors.yml b/contributors.yml index 8e75e7a10f..2fa0c2b29f 100644 --- a/contributors.yml +++ b/contributors.yml @@ -352,6 +352,7 @@ - sanjai451 - sanketshah19 - sapphi-red +- SarthakRawat-1 - saul-atomrigs - sbolel - scarf005 From 63407e1dd56c1bbf8c78e7e1a81f44dd8c127022 Mon Sep 17 00:00:00 2001 From: Sarthak Rawat Date: Sat, 6 Dec 2025 20:04:06 +0530 Subject: [PATCH 3/3] added changeset and unit tests --- .../fix-fetcher-formdata-race-condition.md | 9 ++ .../router/fetcher-race-condition-test.ts | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 .changeset/fix-fetcher-formdata-race-condition.md create mode 100644 packages/react-router/__tests__/router/fetcher-race-condition-test.ts diff --git a/.changeset/fix-fetcher-formdata-race-condition.md b/.changeset/fix-fetcher-formdata-race-condition.md new file mode 100644 index 0000000000..c7b035e21b --- /dev/null +++ b/.changeset/fix-fetcher-formdata-race-condition.md @@ -0,0 +1,9 @@ +--- +"react-router": patch +--- + +Fix fetcher race condition where formData was cleared before new loaderData was available, causing UI flicker in optimistic updates + +- Fixes issue where fetcher.formData became undefined before new loaderData was committed, causing a flicker in optimistic UI updates +- Ensures atomic update of fetcher state and loaderData during navigation completion +- Prevents UI flickering when using optimistic updates with fetchers \ No newline at end of file diff --git a/packages/react-router/__tests__/router/fetcher-race-condition-test.ts b/packages/react-router/__tests__/router/fetcher-race-condition-test.ts new file mode 100644 index 0000000000..2de91e4ee5 --- /dev/null +++ b/packages/react-router/__tests__/router/fetcher-race-condition-test.ts @@ -0,0 +1,96 @@ +import type { Router } from "../../lib/router/router"; +import { createRouter } from "../../lib/router/router"; +import { createMemoryHistory } from "../../lib/router/history"; +import { sleep } from "./utils/utils"; + +describe("Issue #14506: Fetcher race condition with optimistic UI", () => { + let router: Router; + + afterEach(() => { + router?.dispose(); + }); + + it("should keep formData available until loaderData updates (no flicker)", async () => { + let itemStatus = false; + + router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { + id: "root", + path: "/", + children: [ + { + id: "item", + path: "item", + loader: async () => { + await sleep(5); + return { status: itemStatus }; + }, + action: async ({ request }) => { + let formData = await request.formData(); + itemStatus = formData.get("status") === "true"; + await sleep(5); + return { success: true }; + }, + }, + ], + }, + ], + }); + + router.initialize(); + await router.navigate("/item"); + + // Track what UI sees using the pattern from the issue + let transitions: Array<{ + fetcherState: string; + hasFormData: boolean; + loaderDataStatus: boolean; + uiDisplays: boolean; + }> = []; + + router.subscribe((state) => { + let fetcher = state.fetchers.get("toggle"); + let loaderData = state.loaderData["item"]; + + if (fetcher) { + // Exact pattern from issue: const status = (fetcher.formData?.get('status') === 'true') ?? item.status + let displayedStatus = + (fetcher.formData && fetcher.formData.get("status") === "true") ?? + loaderData?.status ?? + false; + + transitions.push({ + fetcherState: fetcher.state, + hasFormData: fetcher.formData !== undefined, + loaderDataStatus: loaderData?.status ?? false, + uiDisplays: displayedStatus, + }); + } + }); + + let formData = new FormData(); + formData.append("status", "true"); + + await router.fetch("toggle", "item", "/item", { + formMethod: "POST", + formData, + }); + + await sleep(50); + + // Check for flicker: true -> false -> true + let uiValues = transitions.map(t => t.uiDisplays); + let hasFlicker = false; + for (let i = 0; i < uiValues.length - 2; i++) { + if (uiValues[i] === true && uiValues[i + 1] === false && uiValues[i + 2] === true) { + hasFlicker = true; + break; + } + } + + expect(hasFlicker).toBe(false); + expect(uiValues[uiValues.length - 1]).toBe(true); + }); +}); \ No newline at end of file