Skip to content

fix initial load 404 scenarios in data mode #13500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fast-planets-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Fix initial load 404 scenarios in data mode
31 changes: 31 additions & 0 deletions packages/react-router/__tests__/router/router-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,37 @@ describe("a router", () => {
router.dispose();
});

it("handles initial load 404s when the error boundary router has a loader", async () => {
let router = createRouter({
history: createMemoryHistory({ initialEntries: ["/404"] }),
routes: [
{
path: "/",
hasErrorBoundary: true,
loader: () => {},
},
],
});

expect(router.state).toMatchObject({
historyAction: "POP",
location: expect.objectContaining({ pathname: "/404" }),
initialized: true,
navigation: IDLE_NAVIGATION,
loaderData: {},
errors: {
"0": new ErrorResponseImpl(
404,
"Not Found",
new Error('No route matches URL "/404"'),
true
),
},
});

router.dispose();
});

it("kicks off initial data load when hash is present", async () => {
let loaderDfd = createDeferred();
let loaderSpy = jest.fn(() => loaderDfd.promise);
Expand Down
119 changes: 62 additions & 57 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,7 @@ export function createRouter(init: RouterInit): Router {
let initialMatches = matchRoutes(dataRoutes, init.history.location, basename);
let initialMatchesIsFOW = false;
let initialErrors: RouteData | null = null;
let initialized: boolean;

if (initialMatches == null && !init.patchRoutesOnNavigation) {
// If we do not match a user-provided-route, fall back to the root
Expand All @@ -878,69 +879,73 @@ export function createRouter(init: RouterInit): Router {
pathname: init.history.location.pathname,
});
let { matches, route } = getShortCircuitMatches(dataRoutes);
initialized = true;
initialMatches = matches;
initialErrors = { [route.id]: error };
}

// In SPA apps, if the user provided a patchRoutesOnNavigation implementation and
// our initial match is a splat route, clear them out so we run through lazy
// discovery on hydration in case there's a more accurate lazy route match.
// In SSR apps (with `hydrationData`), we expect that the server will send
// up the proper matched routes so we don't want to run lazy discovery on
// initial hydration and want to hydrate into the splat route.
if (initialMatches && !init.hydrationData) {
let fogOfWar = checkFogOfWar(
initialMatches,
dataRoutes,
init.history.location.pathname
);
if (fogOfWar.active) {
initialMatches = null;
} else {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this stuff after this initial 404 check is for checking fog of war and checking if we need to kick off any lazy/loader functions etc. But if this was an initial 404 and there is no fog of war implementation, we don't want to bother with any of that since we know we can just initialize with errors synchronously.

// In SPA apps, if the user provided a patchRoutesOnNavigation implementation and
// our initial match is a splat route, clear them out so we run through lazy
// discovery on hydration in case there's a more accurate lazy route match.
// In SSR apps (with `hydrationData`), we expect that the server will send
// up the proper matched routes so we don't want to run lazy discovery on
// initial hydration and want to hydrate into the splat route.
if (initialMatches && !init.hydrationData) {
let fogOfWar = checkFogOfWar(
initialMatches,
dataRoutes,
init.history.location.pathname
);
if (fogOfWar.active) {
initialMatches = null;
}
}
}

let initialized: boolean;
if (!initialMatches) {
initialized = false;
initialMatches = [];

// If partial hydration and fog of war is enabled, we will be running
// `patchRoutesOnNavigation` during hydration so include any partial matches as
// the initial matches so we can properly render `HydrateFallback`'s
let fogOfWar = checkFogOfWar(
null,
dataRoutes,
init.history.location.pathname
);
if (fogOfWar.active && fogOfWar.matches) {
initialMatchesIsFOW = true;
initialMatches = fogOfWar.matches;
}
} else if (initialMatches.some((m) => m.route.lazy)) {
// All initialMatches need to be loaded before we're ready. If we have lazy
// functions around still then we'll need to run them in initialize()
initialized = false;
} else if (!initialMatches.some((m) => m.route.loader)) {
// If we've got no loaders to run, then we're good to go
initialized = true;
} else {
// With "partial hydration", we're initialized so long as we were
// provided with hydrationData for every route with a loader, and no loaders
// were marked for explicit hydration
let loaderData = init.hydrationData ? init.hydrationData.loaderData : null;
let errors = init.hydrationData ? init.hydrationData.errors : null;
// If errors exist, don't consider routes below the boundary
if (errors) {
let idx = initialMatches.findIndex(
(m) => errors![m.route.id] !== undefined
if (!initialMatches) {
initialized = false;
initialMatches = [];

// If partial hydration and fog of war is enabled, we will be running
// `patchRoutesOnNavigation` during hydration so include any partial matches as
// the initial matches so we can properly render `HydrateFallback`'s
let fogOfWar = checkFogOfWar(
null,
dataRoutes,
init.history.location.pathname
);
initialized = initialMatches
.slice(0, idx + 1)
.every((m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors));
if (fogOfWar.active && fogOfWar.matches) {
initialMatchesIsFOW = true;
initialMatches = fogOfWar.matches;
}
} else if (initialMatches.some((m) => m.route.lazy)) {
// All initialMatches need to be loaded before we're ready. If we have lazy
// functions around still then we'll need to run them in initialize()
initialized = false;
} else if (!initialMatches.some((m) => m.route.loader)) {
// If we've got no loaders to run, then we're good to go
initialized = true;
} else {
initialized = initialMatches.every(
(m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
);
// With "partial hydration", we're initialized so long as we were
// provided with hydrationData for every route with a loader, and no loaders
// were marked for explicit hydration
let loaderData = init.hydrationData
? init.hydrationData.loaderData
: null;
let errors = init.hydrationData ? init.hydrationData.errors : null;
// If errors exist, don't consider routes below the boundary
if (errors) {
let idx = initialMatches.findIndex(
(m) => errors![m.route.id] !== undefined
);
initialized = initialMatches
.slice(0, idx + 1)
.every(
(m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
);
} else {
initialized = initialMatches.every(
(m) => !shouldLoadRouteOnHydration(m.route, loaderData, errors)
);
}
}
}

Expand Down