diff --git a/.changeset/purple-boats-bake.md b/.changeset/purple-boats-bake.md new file mode 100644 index 0000000000..ea7600548d --- /dev/null +++ b/.changeset/purple-boats-bake.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Avoid initial fetcher execution 404 error when Lazy Route Discovery is interrupted by a navigation diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 36472a25cf..718710f1dd 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -525,3 +525,76 @@ test.describe("fetcher aborts and adjacent forms", () => { await page.waitForSelector("#idle", { timeout: 2000 }); }); }); + +test.describe("fetcher lazy route discovery", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + test("skips revalidation of initial load fetchers performing lazy route discovery", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + "app/routes/parent.tsx": js` + import * as React from "react"; + import { useFetcher, useNavigate, Outlet } from "react-router"; + + export default function Index() { + const fetcher = useFetcher(); + const navigate = useNavigate(); + + React.useEffect(() => { + fetcher.load('/api'); + }, []); + + React.useEffect(() => { + navigate('/parent/child'); + }, []); + + return ( + <> +

Parent

+ {fetcher.data ? +
{fetcher.data}
: + null} + + + ); + } + `, + "app/routes/parent.child.tsx": js` + export default function Index() { + return

Child

; + } + `, + "app/routes/api.tsx": js` + export async function loader() { + return "FETCHED!" + } + `, + }, + }); + + // Slow down the fetcher discovery a tiny bit so it doesn't resolve prior + // to the navigation + page.route(/\/__manifest/, async (route) => { + console.log(route.request().url()); + if (route.request().url().includes(encodeURIComponent("/api"))) { + await new Promise((r) => setTimeout(r, 100)); + } + route.continue(); + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await page.waitForSelector("h2", { timeout: 3000 }); + await expect(page.locator("h2")).toHaveText("Child"); + await page.waitForSelector("[data-fetcher]", { timeout: 3000 }); + await expect(page.locator("[data-fetcher]")).toHaveText("FETCHED!"); + }); +}); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 3b5c97f9b1..81cf0deffb 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1995,6 +1995,7 @@ export function createRouter(init: RouterInit): Router { fetchRedirectIds, routesToUse, basename, + init.patchRoutesOnNavigation != null, pendingActionResult ); @@ -2441,6 +2442,7 @@ export function createRouter(init: RouterInit): Router { fetchRedirectIds, routesToUse, basename, + init.patchRoutesOnNavigation != null, [match.route.id, actionResult] ); @@ -4583,6 +4585,7 @@ function getMatchesToLoad( fetchRedirectIds: Set, routesToUse: AgnosticDataRouteObject[], basename: string | undefined, + hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult ): { dsMatches: DataStrategyMatch[]; @@ -4717,6 +4720,9 @@ function getMatchesToLoad( return; } + let fetcher = state.fetchers.get(key); + let isMidInitialLoad = + fetcher && fetcher.state !== "idle" && fetcher.data === undefined; let fetcherMatches = matchRoutes(routesToUse, f.path, basename); // If the fetcher path no longer matches, push it in with null matches so @@ -4724,6 +4730,13 @@ function getMatchesToLoad( // currently only a use-case for Remix HMR where the route tree can change // at runtime and remove a route previously loaded via a fetcher if (!fetcherMatches) { + // If this fetcher is still in it's initial loading state, then this is + // most likely not a 404 and the fetcher is still in the middle of lazy + // route discovery so we can just skip revalidation and let it finish + // it's initial load + if (hasPatchRoutesOnNavigation && isMidInitialLoad) { + return; + } revalidatingFetchers.push({ key, routeId: f.routeId, @@ -4744,7 +4757,6 @@ function getMatchesToLoad( // Revalidating fetchers are decoupled from the route matches since they // load from a static href. They revalidate based on explicit revalidation // (submission, useRevalidator, or X-Remix-Revalidate) - let fetcher = state.fetchers.get(key); let fetcherMatch = getTargetMatch(fetcherMatches, f.path); let fetchController = new AbortController(); @@ -4768,11 +4780,7 @@ function getMatchesToLoad( lazyRoutePropertiesToSkip, scopedContext ); - } else if ( - fetcher && - fetcher.state !== "idle" && - fetcher.data === undefined - ) { + } else if (isMidInitialLoad) { if (isRevalidationRequired) { // If the fetcher hasn't ever completed loading yet, then this isn't a // revalidation, it would just be a brand new load if an explicit