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