diff --git a/integration/helpers/rsc-vite-framework/package.json b/integration/helpers/rsc-vite-framework/package.json
index 4f2904a9dd..38d79701a8 100644
--- a/integration/helpers/rsc-vite-framework/package.json
+++ b/integration/helpers/rsc-vite-framework/package.json
@@ -29,7 +29,7 @@
},
"dependencies": {
"@react-router/serve": "workspace:*",
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"express": "^4.22.2",
"react": "catalog:",
diff --git a/integration/helpers/rsc-vite/package.json b/integration/helpers/rsc-vite/package.json
index 04ce969966..48e81b660b 100644
--- a/integration/helpers/rsc-vite/package.json
+++ b/integration/helpers/rsc-vite/package.json
@@ -20,7 +20,7 @@
"vite": "^7.0.0"
},
"dependencies": {
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"cross-env": "^10.1.0",
"express": "^4.22.2",
diff --git a/packages/react-router-dev/.changes/patch.bump-remixrunnodefetchserver-dependency.md b/packages/react-router-dev/.changes/patch.bump-remixrunnodefetchserver-dependency.md
new file mode 100644
index 0000000000..aa6f1c09b2
--- /dev/null
+++ b/packages/react-router-dev/.changes/patch.bump-remixrunnodefetchserver-dependency.md
@@ -0,0 +1 @@
+Bump `@remix-run/node-fetch-server` dependency
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index 36d9004773..ec4cc15ee1 100644
--- a/packages/react-router-dev/package.json
+++ b/packages/react-router-dev/package.json
@@ -95,7 +95,7 @@
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@react-router/node": "workspace:*",
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"babel-dead-code-elimination": "^1.0.12",
"chokidar": "^5.0.0",
"dedent": "^1.7.2",
diff --git a/packages/react-router-node/.changes/patch.bump-remixrunnodefetchserver-dependency.md b/packages/react-router-node/.changes/patch.bump-remixrunnodefetchserver-dependency.md
new file mode 100644
index 0000000000..aa6f1c09b2
--- /dev/null
+++ b/packages/react-router-node/.changes/patch.bump-remixrunnodefetchserver-dependency.md
@@ -0,0 +1 @@
+Bump `@remix-run/node-fetch-server` dependency
diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json
index c030b65222..37e9c59645 100644
--- a/packages/react-router-node/package.json
+++ b/packages/react-router-node/package.json
@@ -42,7 +42,7 @@
}
},
"dependencies": {
- "@remix-run/node-fetch-server": "^0.13.3"
+ "@remix-run/node-fetch-server": "^0.14.1"
},
"devDependencies": {
"@types/node": "^22.19.19",
diff --git a/packages/react-router-serve/.changes/patch.bump-remixrunnodefetchserver-dependency.md b/packages/react-router-serve/.changes/patch.bump-remixrunnodefetchserver-dependency.md
new file mode 100644
index 0000000000..aa6f1c09b2
--- /dev/null
+++ b/packages/react-router-serve/.changes/patch.bump-remixrunnodefetchserver-dependency.md
@@ -0,0 +1 @@
+Bump `@remix-run/node-fetch-server` dependency
diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json
index 906ef0f3f3..384b290c75 100644
--- a/packages/react-router-serve/package.json
+++ b/packages/react-router-serve/package.json
@@ -40,7 +40,7 @@
"dependencies": {
"@react-router/express": "workspace:*",
"@react-router/node": "workspace:*",
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"express": "^5.2.1",
"morgan": "^1.10.1",
diff --git a/packages/react-router/.changes/patch.fog-of-war-aborted-discovery.md b/packages/react-router/.changes/patch.fog-of-war-aborted-discovery.md
new file mode 100644
index 0000000000..19f5d9e3c6
--- /dev/null
+++ b/packages/react-router/.changes/patch.fog-of-war-aborted-discovery.md
@@ -0,0 +1 @@
+Fix lazy route discovery caching a path as discovered when the triggering navigation was aborted after the manifest response settled but before the route tree was patched, which permanently (for the session) shadowed the real route behind a catch-all or produced 404s on every subsequent visit
diff --git a/packages/react-router/.changes/patch.validate-client-navigation-targets.md b/packages/react-router/.changes/patch.validate-client-navigation-targets.md
new file mode 100644
index 0000000000..5aa93973cc
--- /dev/null
+++ b/packages/react-router/.changes/patch.validate-client-navigation-targets.md
@@ -0,0 +1 @@
+Add additional URL validation on client side navigations/redirects
diff --git a/packages/react-router/__tests__/external-navigation-test.tsx b/packages/react-router/__tests__/external-navigation-test.tsx
new file mode 100644
index 0000000000..b96e5733f1
--- /dev/null
+++ b/packages/react-router/__tests__/external-navigation-test.tsx
@@ -0,0 +1,425 @@
+import * as React from "react";
+import * as TestRenderer from "react-test-renderer";
+
+import type { Navigator } from "../lib/context";
+import { setup } from "./router/utils/data-router-setup";
+import { createBrowserHistory } from "../lib/router/history";
+import {
+ getNavigatorCurrentUrl,
+ validateNavigationTarget,
+} from "../lib/router/navigation";
+import { createRouter } from "../lib/router/router";
+import { Navigate, Route, Router, Routes } from "../lib/components";
+import { useNavigate } from "../lib/hooks";
+import { redirect } from "../lib/router/utils";
+import getWindow from "./utils/getWindow";
+
+const EXPLICIT_EXTERNAL_URLS = [
+ "http://example.com/path",
+ "https://example.com/path",
+ "//example.com/path",
+ "mailto:test@example.com",
+ "tel:5551234567",
+];
+
+const IMPLICIT_EXTERNAL_URLS = [
+ "/\t/example.com/path",
+ "/\\example.com/path",
+ "\\/example.com/path",
+ "\\\\example.com/path",
+];
+
+const INVALID_PROTOCOL_URLS = [
+ // eslint-disable-next-line no-script-url
+ "javascript:console.log('hi')",
+ "data:foo",
+];
+
+const EXTERNAL_URLS = [
+ ...EXPLICIT_EXTERNAL_URLS,
+ ...IMPLICIT_EXTERNAL_URLS,
+ ...INVALID_PROTOCOL_URLS,
+];
+
+const VALID_INTERNAL_URLS = [
+ {
+ original: "\\example.com/path",
+ resolved: "/example.com/path",
+ },
+];
+
+describe("external navigation support", () => {
+ it.each(EXTERNAL_URLS)(
+ "router.navigate throws for external navigation to %s",
+ async (to) => {
+ let t = setup({
+ routes: [{ id: "index", index: true }],
+ });
+
+ await expect(t.router.navigate(to)).rejects.toThrow(
+ "External navigation is not allowed",
+ );
+ expect(t.router.state.location.pathname).toBe("/");
+ },
+ );
+
+ it.each(VALID_INTERNAL_URLS)(
+ "router.navigate does not throw for an internal navigation to %s",
+ async (to) => {
+ let t = setup({
+ routes: [{ id: "index", index: true }],
+ });
+
+ await t.router.navigate(to.original);
+ expect(t.router.state.location.pathname).toBe(to.resolved);
+ expect(t.history.push).toHaveBeenCalledWith(
+ expect.objectContaining({ pathname: to.resolved }),
+ null,
+ );
+ },
+ );
+
+ it.each(EXTERNAL_URLS)(
+ "router.navigate throws for an external navigation mask to %s",
+ async (to) => {
+ let t = setup({
+ routes: [
+ { id: "index", index: true },
+ { id: "internal", path: "/internal" },
+ ],
+ });
+
+ await expect(
+ t.router.navigate("/internal", { mask: to }),
+ ).rejects.toThrow("External navigation is not allowed");
+ expect(t.router.state.location.pathname).toBe("/");
+ },
+ );
+
+ it.each(VALID_INTERNAL_URLS)(
+ "router.navigate does not throw for an internal navigation mask to %s",
+ async (to) => {
+ let t = setup({
+ routes: [
+ { id: "index", index: true },
+ { id: "internal", path: "/internal" },
+ ],
+ });
+
+ await t.router.navigate("/internal", { mask: to.original });
+ expect(t.router.state.location.pathname).toBe("/internal");
+ expect(t.history.push).toHaveBeenCalledWith(
+ expect.objectContaining({
+ pathname: "/internal",
+ mask: expect.objectContaining({ pathname: to.resolved }),
+ }),
+ null,
+ );
+ },
+ );
+
+ it.each(EXTERNAL_URLS)(
+ " throws for external navigation to %s",
+ (to) => {
+ let testWindow = getWindow("/");
+ let history = createBrowserHistory({ window: testWindow });
+ let pushSpy = jest.spyOn(history, "push");
+
+ expect(() => {
+ TestRenderer.act(() => {
+ TestRenderer.create(
+
+
+ } />
+ Internal} />
+
+ ,
+ );
+ });
+ }).toThrow("External navigation is not allowed");
+
+ expect(pushSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(VALID_INTERNAL_URLS)(
+ " does not throw for internal navigation to %s",
+ async (to) => {
+ let testWindow = getWindow("/");
+ let history = createBrowserHistory({ window: testWindow });
+ let pushSpy = jest.spyOn(history, "push");
+
+ let routes = (
+
+ } />
+ Internal} />
+
+ );
+
+ TestRenderer.act(() => {
+ TestRenderer.create(
+
+ {routes}
+ ,
+ );
+ });
+
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(pushSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ pathname: to.resolved }),
+ undefined,
+ expect.any(Object),
+ );
+ },
+ );
+
+ it.each(EXTERNAL_URLS)(
+ "useNavigate throws for external navigation to %s",
+ (to) => {
+ function Home() {
+ let navigate = useNavigate();
+ return ;
+ }
+
+ let testWindow = getWindow("/");
+ let history = createBrowserHistory({ window: testWindow });
+ let pushSpy = jest.spyOn(history, "push");
+
+ let renderer: TestRenderer.ReactTestRenderer;
+ TestRenderer.act(() => {
+ renderer = TestRenderer.create(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ expect(() => renderer.root.findByType("button").props.onClick()).toThrow(
+ "External navigation is not allowed",
+ );
+ expect(pushSpy).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(VALID_INTERNAL_URLS)(
+ "useNavigate does not throw for internal navigation to %s",
+ (to) => {
+ function Home() {
+ let navigate = useNavigate();
+ return ;
+ }
+
+ let testWindow = getWindow("/");
+ let history = createBrowserHistory({ window: testWindow });
+ let pushSpy = jest.spyOn(history, "push");
+
+ let renderer: TestRenderer.ReactTestRenderer;
+ TestRenderer.act(() => {
+ renderer = TestRenderer.create(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ TestRenderer.act(() =>
+ renderer.root.findByType("button").props.onClick(),
+ );
+ expect(pushSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ pathname: to.resolved }),
+ undefined,
+ expect.any(Object),
+ );
+ },
+ );
+
+ it.each(IMPLICIT_EXTERNAL_URLS)(
+ "client-side redirect rejects implicit external redirects to %s",
+ async (location) => {
+ let testWindow = getWindow("/");
+ testWindow = {
+ ...testWindow,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ location: {
+ ...testWindow.location,
+ assign: jest.fn(),
+ replace: jest.fn(),
+ },
+ } as unknown as Window;
+ let router = createRouter({
+ history: createBrowserHistory(),
+ routes: [
+ { path: "/" },
+ {
+ path: "/start",
+ loader: () => redirect(location),
+ },
+ ],
+ window: testWindow,
+ }).initialize();
+
+ await expect(router.navigate("/start")).rejects.toThrow(
+ "External navigation is not allowed",
+ );
+ expect(testWindow.location.assign).not.toHaveBeenCalled();
+ router.dispose();
+ },
+ );
+
+ it.each(INVALID_PROTOCOL_URLS)(
+ "client-side redirect rejects implicit external redirects to %s",
+ async (location) => {
+ let testWindow = getWindow("/");
+ testWindow = {
+ ...testWindow,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ location: {
+ ...testWindow.location,
+ assign: jest.fn(),
+ replace: jest.fn(),
+ },
+ } as unknown as Window;
+ let router = createRouter({
+ history: createBrowserHistory(),
+ routes: [
+ { path: "/" },
+ {
+ path: "/start",
+ loader: () => redirect(location),
+ },
+ ],
+ window: testWindow,
+ }).initialize();
+
+ await expect(router.navigate("/start")).rejects.toThrow(
+ "Invalid redirect location",
+ );
+ expect(testWindow.location.assign).not.toHaveBeenCalled();
+ router.dispose();
+ },
+ );
+
+ it.each(EXPLICIT_EXTERNAL_URLS)(
+ "client-side redirects permit explicit external redirects to %s",
+ async (location) => {
+ let testWindow = getWindow("/");
+ testWindow = {
+ ...testWindow,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ location: {
+ ...testWindow.location,
+ assign: jest.fn(),
+ replace: jest.fn(),
+ },
+ } as unknown as Window;
+ let router = createRouter({
+ history: createBrowserHistory(),
+ routes: [
+ { path: "/" },
+ {
+ path: "/start",
+ loader: () => redirect(location),
+ },
+ ],
+ window: testWindow,
+ }).initialize();
+
+ await router.navigate("/start");
+ expect(testWindow.location.assign).toHaveBeenCalledWith(location);
+ router.dispose();
+ },
+ );
+
+ it.each(VALID_INTERNAL_URLS)(
+ "client-side redirects permit valid internal redirects to %s",
+ async (to) => {
+ let testWindow = getWindow("/");
+ testWindow = {
+ ...testWindow,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ location: {
+ ...testWindow.location,
+ assign: jest.fn(),
+ replace: jest.fn(),
+ },
+ } as unknown as Window;
+ let history = createBrowserHistory();
+ jest.spyOn(history, "push");
+ let router = createRouter({
+ history,
+ routes: [
+ { path: "/" },
+ {
+ path: "/start",
+ loader: () => redirect(to.original),
+ },
+ ],
+ window: testWindow,
+ }).initialize();
+
+ await router.navigate("/start");
+ expect(history.push).toHaveBeenCalledWith(
+ expect.objectContaining({ pathname: to.resolved }),
+ expect.any(Object),
+ );
+ router.dispose();
+ },
+ );
+
+ it("uses an absolute createHref as a custom navigator base", () => {
+ let navigator = {
+ createHref: () => "https://custom.example/",
+ } as unknown as Navigator;
+
+ expect(getNavigatorCurrentUrl(navigator).origin).toBe(
+ "https://custom.example",
+ );
+ });
+
+ it("rejects an invalid resolved destination", () => {
+ expect(() =>
+ validateNavigationTarget(
+ "/internal",
+ "http://[",
+ new URL("https://app.example/"),
+ "reject",
+ ),
+ ).toThrow();
+ });
+
+ it("rejects an explicit destination whose origin changed", () => {
+ expect(() =>
+ validateNavigationTarget(
+ "https://first.example/path",
+ "https://second.example/path",
+ new URL("https://app.example/"),
+ "allow-explicit",
+ ),
+ ).toThrow("External navigation is not allowed");
+ });
+});
diff --git a/packages/react-router/__tests__/router/lazy-discovery-aborted-patch-test.ts b/packages/react-router/__tests__/router/lazy-discovery-aborted-patch-test.ts
new file mode 100644
index 0000000000..a40014e233
--- /dev/null
+++ b/packages/react-router/__tests__/router/lazy-discovery-aborted-patch-test.ts
@@ -0,0 +1,278 @@
+/**
+ * Regression tests for https://github.com/remix-run/react-router/issues/15327
+ *
+ * `fetchAndApplyManifestPatches` used to mutate `manifest.routes` and add the
+ * path to the module-singleton `discoveredPaths` FIFO unconditionally once the
+ * `/__manifest` response body settled, while the router's `patch` callback
+ * no-ops when the triggering navigation/fetcher signal has aborted in the
+ * meantime. An abort landing in that window cached the path as "discovered"
+ * for the rest of the session while the route tree was never patched, so
+ * every subsequent visit matched an ambiguous route (e.g., a catch-all)
+ * instead of the real one - or 404ed when nothing matched.
+ *
+ * These tests use the real `getPatchRoutesOnNavigationFunction` wired to the
+ * real router; only `global.fetch` is mocked, exactly like the network layer
+ * is the only thing mocked in lazy-discovery-test.ts.
+ */
+import { type AssetsManifest } from "../../lib/dom/ssr/entry";
+import { getPatchRoutesOnNavigationFunction } from "../../lib/dom/ssr/fog-of-war";
+import { createMemoryHistory } from "../../lib/router/history";
+import { type Router, createRouter } from "../../lib/router/router";
+import { getFetcherData } from "./utils/data-router-setup";
+import { createDeferred, tick } from "./utils/utils";
+
+let router: Router;
+
+function makeEntryRoute(
+ id: string,
+ parentId: string | undefined,
+ path: string | undefined,
+ opts: Partial<{ index: boolean; hasLoader: boolean }> = {},
+) {
+ return {
+ id,
+ parentId,
+ path,
+ index: opts.index,
+ module: `/build/${id.replace(/[^a-zA-Z0-9]/g, "_")}.js`,
+ hasAction: false,
+ hasLoader: opts.hasLoader ?? false,
+ hasClientAction: false,
+ hasClientLoader: false,
+ hasClientMiddleware: false,
+ hasErrorBoundary: false,
+ };
+}
+
+function makeManifest(): AssetsManifest {
+ return {
+ entry: { imports: [], module: "/build/entry.client.js" },
+ url: "/build/manifest.js",
+ version: "single-build-abc123",
+ routes: {
+ root: makeEntryRoute("root", undefined, ""),
+ "routes/_index": makeEntryRoute("routes/_index", "root", undefined, {
+ index: true,
+ }),
+ "routes/$": makeEntryRoute("routes/$", "root", "*", { hasLoader: true }),
+ },
+ };
+}
+
+/**
+ * Minimal manifest-response mock. `json()` resolution is controlled by the
+ * test so we can interleave the abort exactly where it lands in production:
+ * the response body has fully settled, but the awaiting continuation in
+ * `fetchAndApplyManifestPatches` has not run yet when a user-triggered event
+ * (click -> navigate) aborts the signal synchronously. Once `json()` has
+ * settled, a real fetch abort is a no-op too, so this mock is
+ * behavior-accurate for that window.
+ */
+function mockManifestFetch(jsonDfd: { promise: Promise }) {
+ let fetchMock = jest.fn().mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ headers: new Headers(),
+ json: () => jsonDfd.promise,
+ text: () => Promise.resolve(""),
+ }),
+ );
+ // @ts-expect-error - jsdom global
+ global.fetch = fetchMock;
+
+ return fetchMock;
+}
+
+function createAppRouter(
+ manifest: AssetsManifest,
+ catchallLoader: (args: { request: Request }) => unknown,
+ { includeCatchall = true } = {},
+) {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ {
+ id: "root",
+ path: "",
+ children: [
+ { id: "routes/_index", index: true },
+ ...(includeCatchall
+ ? [{ id: "routes/$", path: "*", loader: catchallLoader }]
+ : []),
+ ],
+ },
+ ],
+ // The real production fog-of-war implementation
+ patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction(
+ () => router,
+ manifest,
+ /* routeModules */ {},
+ /* ssr */ true,
+ /* routeDiscovery */ { mode: "lazy", manifestPath: "/__manifest" },
+ /* isSpaMode */ false,
+ /* basename */ undefined,
+ ),
+ });
+}
+
+describe("aborted lazy route discovery does not poison the discovery cache", () => {
+ afterEach(() => {
+ router.dispose();
+ // @ts-expect-error
+ router = null;
+ });
+
+ it("[control] non-aborted discovery patches the real route into the tree", async () => {
+ let manifest = makeManifest();
+ let jsonDfd = createDeferred();
+ let fetchMock = mockManifestFetch(jsonDfd);
+ router = createAppRouter(manifest, () => "SPLAT");
+
+ router.navigate("/real-ctrl");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // Manifest response arrives, nothing aborts it
+ jsonDfd.resolve({
+ "routes/real-ctrl": makeEntryRoute(
+ "routes/real-ctrl",
+ "root",
+ "real-ctrl",
+ ),
+ });
+ await tick();
+ await tick();
+
+ // Route tree WAS patched -> real route now exists as a sibling of routes/$
+ let rootChildren = router.routes[0].children!.map((r) => r.id);
+ expect(rootChildren).toContain("routes/real-ctrl");
+ });
+
+ it("re-discovers a path whose discovery was aborted after the manifest response settled", async () => {
+ let manifest = makeManifest();
+ let jsonDfd = createDeferred();
+ let fetchMock = mockManifestFetch(jsonDfd);
+ let catchallLoader = jest.fn(
+ ({ request }: { request: Request }) => `SPLAT:${request.url}`,
+ );
+ router = createAppRouter(manifest, catchallLoader);
+
+ // 1) Navigate to a route the client tree doesn't know yet -> discovery
+ router.navigate("/real");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // 2) The /__manifest response body settles...
+ jsonDfd.resolve({
+ "routes/real": makeEntryRoute("routes/real", "root", "real"),
+ });
+ // 3) ...and in the same task (before the awaiting continuation in
+ // fetchAndApplyManifestPatches runs) the user clicks something else.
+ // startNavigation aborts pendingNavigationController synchronously.
+ router.navigate("/");
+ await tick();
+ expect(router.state.location.pathname).toBe("/");
+
+ // Nothing was cached or mutated for the aborted discovery
+ expect(manifest.routes["routes/real"]).toBeUndefined();
+ expect(router.routes[0].children!.map((r) => r.id)).not.toContain(
+ "routes/real",
+ );
+
+ // 4) Same session, same build: navigate to /real again -> discovery
+ // re-runs (the path was NOT marked as discovered) and the tree is
+ // patched with the real route, which wins over the catch-all
+ router.navigate("/real");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ await tick();
+ await tick();
+
+ expect(manifest.routes["routes/real"]).toBeDefined();
+ expect(router.routes[0].children!.map((r) => r.id)).toContain(
+ "routes/real",
+ );
+ // The real route - not the catch-all - is what the navigation targets
+ // (the navigation itself can't complete in jsdom because the patched
+ // route's module can't be imported, which is unrelated to discovery)
+ expect(catchallLoader).not.toHaveBeenCalled();
+ });
+
+ it("re-discovers on fetcher.load after a navigation discovery for the path was aborted", async () => {
+ let manifest = makeManifest();
+ let jsonDfd = createDeferred();
+ let fetchMock = mockManifestFetch(jsonDfd);
+ let catchallLoader = jest.fn(
+ ({ request }: { request: Request }) => `SPLAT:${request.url}`,
+ );
+ router = createAppRouter(manifest, catchallLoader);
+ getFetcherData(router);
+
+ // Aborted navigation discovery for the path (discoveredPaths is a
+ // module-level singleton shared across navigations and fetchers)
+ router.navigate("/api/widget");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ jsonDfd.resolve({
+ "routes/api.widget": makeEntryRoute(
+ "routes/api.widget",
+ "root",
+ "api/widget",
+ ),
+ });
+ router.navigate("/");
+ await tick();
+
+ // Not poisoned
+ expect(manifest.routes["routes/api.widget"]).toBeUndefined();
+
+ // A widget mounts and calls fetcher.load -> discovery re-runs and the
+ // real route (not the catch-all) becomes the fetcher's match
+ router.fetch("widget-fetcher", "root", "/api/widget?x=1");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ await tick();
+ await tick();
+
+ expect(router.routes[0].children!.map((r) => r.id)).toContain(
+ "routes/api.widget",
+ );
+ expect(catchallLoader).not.toHaveBeenCalled();
+ });
+
+ it("does not 404 a fetcher for a path whose discovery was aborted (no catch-all)", async () => {
+ let manifest = makeManifest();
+ delete manifest.routes["routes/$"];
+ let jsonDfd = createDeferred();
+ let fetchMock = mockManifestFetch(jsonDfd);
+ router = createAppRouter(manifest, () => null, { includeCatchall: false });
+ getFetcherData(router);
+
+ // Aborted navigation discovery for the path
+ router.navigate("/api/tenants");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ jsonDfd.resolve({
+ "routes/api.tenants": makeEntryRoute(
+ "routes/api.tenants",
+ "root",
+ "api/tenants",
+ ),
+ });
+ router.navigate("/");
+ await tick();
+
+ router.fetch("tenants-fetcher", "root", "/api/tenants?userId=u1");
+ await tick();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ await tick();
+ await tick();
+
+ // The path was re-discovered and matched - no 404
+ expect(router.routes[0].children!.map((r) => r.id)).toContain(
+ "routes/api.tenants",
+ );
+ expect(router.state.errors).toBeFalsy();
+ });
+});
diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts
index dd605f374b..35120308ca 100644
--- a/packages/react-router/__tests__/router/utils/data-router-setup.ts
+++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts
@@ -25,6 +25,7 @@ import {
} from "../../../lib/router/utils";
import { isRedirect, tick } from "./utils";
+import getWindow from "../../utils/getWindow";
// Routes passed into setup() should just have a boolean for loader/action
// indicating they want a stub. They get enhanced back to AgnosticRouteObjects
@@ -309,10 +310,13 @@ export function setup({
// jsdom is making more and more properties non-configurable, so we inject
// our own jest-friendly window.
- let testWindow = {
- ...window,
+ let testWindow = getWindow("/");
+ testWindow = {
+ ...testWindow,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
location: {
- ...window.location,
+ ...testWindow.location,
assign: jest.fn(),
replace: jest.fn(),
},
diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx
index 1e0abc2e5e..743aed2709 100644
--- a/packages/react-router/__tests__/useNavigate-test.tsx
+++ b/packages/react-router/__tests__/useNavigate-test.tsx
@@ -761,33 +761,6 @@ describe("useNavigate", () => {
`);
});
-
- it("normalizes mixed leading separators", async () => {
- for (let to of ["//foo", "\\\\foo", "/\\foo", "\\/foo"]) {
- let renderer: TestRenderer.ReactTestRenderer;
- TestRenderer.act(() => {
- renderer = TestRenderer.create(
-
-
- } />
- foo
} />
-
- ,
- );
- });
-
- // @ts-expect-error
- let button = renderer.root.findByType("button");
- await TestRenderer.act(() => button.props.onClick());
-
- // @ts-expect-error
- expect(renderer.toJSON()).toMatchInlineSnapshot(`
-
- foo
-
- `);
- }
- });
});
describe("with a relative href (relative=route)", () => {
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index db1d051973..9a2858900e 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -8,6 +8,7 @@ import type {
} from "./router/history";
import {
Action as NavigationType,
+ createPath,
createMemoryHistory,
invariant,
parsePath,
@@ -74,6 +75,10 @@ import {
import type { ViewTransition } from "./dom/global";
import { warnOnce } from "./server-runtime/warnings";
import type { ClientInstrumentation } from "./router/instrumentation";
+import {
+ getNavigatorCurrentUrl,
+ validateNavigationTarget,
+} from "./router/navigation";
import { useOptimistic } from "react";
export const hydrationRouteProperties: (keyof RouteObject)[] = [
@@ -618,6 +623,7 @@ export function RouterProvider({
let navigator = React.useMemo((): Navigator => {
return {
createHref: router.createHref,
+ createURL: router.createURL,
encodeLocation: router.encodeLocation,
go: (n) => router.navigate(n),
push: (to, state, opts) =>
@@ -893,7 +899,7 @@ export function Navigate({
` may be used only in the context of a component.`,
);
- let { static: isStatic } = React.useContext(NavigationContext);
+ let { static: isStatic, navigator } = React.useContext(NavigationContext);
warning(
!isStatic,
@@ -914,6 +920,12 @@ export function Navigate({
locationPathname,
relative === "path",
);
+ validateNavigationTarget(
+ typeof to === "string" ? to : createPath(to),
+ navigator.createHref(path),
+ getNavigatorCurrentUrl(navigator),
+ "reject",
+ );
let jsonPath = JSON.stringify(path);
React.useEffect(() => {
diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts
index d071d8e836..cedf31bcdd 100644
--- a/packages/react-router/lib/context.ts
+++ b/packages/react-router/lib/context.ts
@@ -101,6 +101,8 @@ export interface NavigateOptions {
export interface Navigator {
createHref: History["createHref"];
// Optional for backwards-compat with Router/HistoryRouter usage (edge case)
+ createURL?: History["createURL"];
+ // Optional for backwards-compat with Router/HistoryRouter usage (edge case)
encodeLocation?: History["encodeLocation"];
go: History["go"];
push(to: To, state?: any, opts?: NavigateOptions): void;
diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts
index 78016c81f6..41432b4f34 100644
--- a/packages/react-router/lib/dom/ssr/fog-of-war.ts
+++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts
@@ -358,6 +358,17 @@ export async function fetchAndApplyManifestPatches(
throw e;
}
+ // If the navigation/fetcher that triggered this discovery was aborted
+ // while we were waiting on the response, bail before applying anything.
+ // The router's `patch` callback no-ops on an aborted signal, so mutating
+ // `manifest.routes`/`discoveredPaths` here would cache the path as
+ // discovered while the route tree is never patched - permanently (for the
+ // session) shadowing the real route behind any ambiguous match (e.g., a
+ // catch-all) on every subsequent visit
+ if (signal?.aborted) {
+ return;
+ }
+
// Patch routes we don't know about yet into the manifest
let knownRoutes = new Set(Object.keys(manifest.routes));
let patches = Object.values(serverPatches).reduce((acc, route) => {
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index ca1e1561a4..435b087e3d 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -13,6 +13,7 @@ import {
import type { Location, Path, To } from "./router/history";
import {
Action as NavigationType,
+ createPath,
invariant,
parsePath,
warning,
@@ -63,6 +64,10 @@ import {
decodeRedirectErrorDigest,
decodeRouteErrorResponseDigest,
} from "./errors";
+import {
+ getNavigatorCurrentUrl,
+ validateNavigationTarget,
+} from "./router/navigation";
/**
* Resolves a URL against the current {@link Location}.
@@ -424,6 +429,13 @@ function useNavigateUnstable(): NavigateFunction {
: joinPaths([basename, path.pathname]);
}
+ validateNavigationTarget(
+ typeof to === "string" ? to : createPath(to),
+ navigator.createHref(path),
+ getNavigatorCurrentUrl(navigator),
+ "reject",
+ );
+
(!!options.replace ? navigator.replace : navigator.push)(
path,
options.state,
@@ -1117,7 +1129,7 @@ function RSCErrorHandler({
children: React.ReactNode;
error: unknown;
}) {
- let { basename } = React.useContext(NavigationContext);
+ let { basename, navigator } = React.useContext(NavigationContext);
if (
typeof error === "object" &&
@@ -1132,6 +1144,12 @@ function RSCErrorHandler({
let parsed = parseToInfo(redirect.location, basename);
let target = parsed.absoluteURL || parsed.to;
+ validateNavigationTarget(
+ redirect.location,
+ target,
+ getNavigatorCurrentUrl(navigator),
+ "allow-explicit",
+ );
if (hasInvalidProtocol(target)) {
throw new Error("Invalid redirect location");
}
diff --git a/packages/react-router/lib/router/navigation.ts b/packages/react-router/lib/router/navigation.ts
new file mode 100644
index 0000000000..8fccaa4d4d
--- /dev/null
+++ b/packages/react-router/lib/router/navigation.ts
@@ -0,0 +1,69 @@
+import type { Navigator } from "../context";
+
+const DEFAULT_NAVIGATION_URL = new URL("http://localhost");
+
+export type ExternalNavigationPolicy = "allow-explicit" | "reject";
+
+export function getNavigatorCurrentUrl(navigator: Navigator): URL {
+ if (navigator.createURL) {
+ return navigator.createURL("/");
+ }
+
+ try {
+ return new URL(navigator.createHref("/"), DEFAULT_NAVIGATION_URL);
+ } catch {
+ return DEFAULT_NAVIGATION_URL;
+ }
+}
+
+function isSameOrigin(a: URL, b: URL): boolean {
+ return (
+ a.origin === b.origin &&
+ (a.origin !== "null" || (a.protocol === b.protocol && a.host === b.host))
+ );
+}
+
+function isExplicitUrl(destination: string, target: URL): boolean {
+ if (destination.startsWith("//")) {
+ return true;
+ }
+
+ let protocol = target.protocol.toLowerCase();
+ if (!destination.toLowerCase().startsWith(protocol)) {
+ return false;
+ }
+
+ return (
+ target.host === "" || destination.slice(protocol.length).startsWith("//")
+ );
+}
+
+export function validateNavigationTarget(
+ original: string | null,
+ resolved: string,
+ currentUrl: URL,
+ externalPolicy: ExternalNavigationPolicy,
+): void {
+ let originalUrl: URL | null = null;
+ try {
+ originalUrl = original == null ? null : new URL(original, currentUrl);
+ } catch {}
+ let resolvedUrl = new URL(resolved, currentUrl);
+ let originalIsExternal =
+ originalUrl != null && !isSameOrigin(originalUrl, currentUrl);
+ let resolvedIsExternal = !isSameOrigin(resolvedUrl, currentUrl);
+
+ if (externalPolicy === "reject") {
+ if (originalIsExternal || resolvedIsExternal) {
+ throw new Error("External navigation is not allowed");
+ }
+ } else if (resolvedIsExternal) {
+ if (
+ originalUrl == null ||
+ !isExplicitUrl(original!, originalUrl) ||
+ !isSameOrigin(originalUrl, resolvedUrl)
+ ) {
+ throw new Error("External navigation is not allowed");
+ }
+ }
+}
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index db266eab0e..0dc53c7fcf 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -74,11 +74,13 @@ import {
getRoutePattern,
removeDoubleSlashes,
flattenAndRankRoutes,
+ resolvePath,
} from "./utils";
import {
normalizeProtocolRelativeUrl,
PROTOCOL_RELATIVE_URL_REGEX,
} from "./url";
+import { validateNavigationTarget } from "./navigation";
////////////////////////////////////////////////////////////////////////////////
//#region Types and Constants
@@ -232,6 +234,15 @@ export interface Router {
*/
createHref(location: Location | URL): string;
+ /**
+ * @private
+ * PRIVATE - DO NOT USE
+ *
+ * Utility function to create a URL for the given location
+ * @param location
+ */
+ createURL?(to: To): URL;
+
/**
* @private
* PRIVATE - DO NOT USE
@@ -1708,11 +1719,23 @@ export function createRouter(init: RouterInit): Router {
...opts.mask,
};
maskPath = {
- pathname: "",
- search: "",
- hash: "",
- ...partialPath,
+ pathname: partialPath.pathname ?? "",
+ search: partialPath.search ?? "",
+ hash: partialPath.hash ?? "",
};
+
+ if (PROTOCOL_RELATIVE_URL_REGEX.test(maskPath.pathname)) {
+ throw new Error("External navigation is not allowed");
+ } else if (maskPath.pathname.startsWith("\\")) {
+ maskPath.pathname = maskPath.pathname.replace(/^\\+/, "/");
+ }
+
+ validateNavigationTarget(
+ typeof opts.mask === "string" ? opts.mask : createPath(opts.mask),
+ createPath(maskPath),
+ init.history.createURL("/"),
+ "reject",
+ );
}
let currentLocation = state.location;
@@ -1734,6 +1757,17 @@ export function createRouter(init: RouterInit): Router {
...init.history.encodeLocation(nextLocation),
};
+ validateNavigationTarget(
+ to == null
+ ? init.history.createHref(state.location)
+ : typeof to === "string"
+ ? to
+ : createPath(to),
+ init.history.createHref(nextLocation.mask || nextLocation),
+ init.history.createURL("/"),
+ "reject",
+ );
+
let userReplace = opts && opts.replace != null ? opts.replace : undefined;
let historyAction = NavigationType.Push;
@@ -3251,12 +3285,20 @@ export function createRouter(init: RouterInit): Router {
let location = redirect.response.headers.get("Location");
invariant(location, "Expected a Location header on the redirect Response");
+ let originalLocation = location;
+ let currentUrl = new URL(request.url);
location = normalizeRedirectLocation(
location,
- new URL(request.url),
+ currentUrl,
basename,
init.history,
);
+ validateNavigationTarget(
+ originalLocation,
+ location,
+ currentUrl,
+ "allow-explicit",
+ );
let redirectLocation = createLocation(state.location, location, {
_isRedirect: true,
});
@@ -4013,6 +4055,7 @@ export function createRouter(init: RouterInit): Router {
// Passthrough to history-aware createHref used by useHref so we get proper
// hash-aware URLs in DOM paths
createHref: (to: To) => init.history.createHref(to),
+ createURL: (to: To) => init.history.createURL(to),
encodeLocation: (to: To) => init.history.encodeLocation(to),
getFetcher,
resetFetcher,
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index fdc6c62c9b..71d782e2c8 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -1875,7 +1875,7 @@ export function resolvePath(to: To, fromPathname = "/"): Path {
let pathname: string;
if (toPathname) {
toPathname = removeDoubleSlashes(toPathname);
- if (toPathname.startsWith("/")) {
+ if (toPathname.startsWith("/") || toPathname.startsWith("\\")) {
pathname = resolvePathname(toPathname.substring(1), "/");
} else {
pathname = resolvePathname(toPathname, fromPathname);
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index 0daef296f5..f883b77fb1 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -26,6 +26,7 @@ import type {
} from "../router/utils";
import { ErrorResponseImpl, createContext, resolvePath } from "../router/utils";
import { PROTOCOL_RELATIVE_URL_REGEX } from "../router/url";
+import { validateNavigationTarget } from "../router/navigation";
import type {
DecodedSingleFetchResults,
FetchAndDecodeFunction,
@@ -151,6 +152,12 @@ export function createCallServer({
.then(async (payload) => {
if (payload.type === "redirect") {
let location = normalizeRedirectLocation(payload.location);
+ validateNavigationTarget(
+ payload.location,
+ location,
+ new URL(window.location.href),
+ "allow-explicit",
+ );
if (payload.reload || isExternalLocation(location)) {
if (hasInvalidProtocol(location)) {
throw new Error("Invalid redirect location");
@@ -179,6 +186,12 @@ export function createCallServer({
) {
if (rerender.type === "redirect") {
let location = normalizeRedirectLocation(rerender.location);
+ validateNavigationTarget(
+ rerender.location,
+ location,
+ new URL(window.location.href),
+ "allow-explicit",
+ );
if (rerender.reload || isExternalLocation(location)) {
if (hasInvalidProtocol(location)) {
throw new Error("Invalid redirect location");
diff --git a/playground/rsc-vite-7-framework/package.json b/playground/rsc-vite-7-framework/package.json
index 9fc7ce2da9..4182c4b1c2 100644
--- a/playground/rsc-vite-7-framework/package.json
+++ b/playground/rsc-vite-7-framework/package.json
@@ -28,7 +28,7 @@
"vite": "^7.3.1"
},
"dependencies": {
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"express": "^4.22.2",
"react": "catalog:",
diff --git a/playground/rsc-vite-framework/package.json b/playground/rsc-vite-framework/package.json
index e02b6b3575..3e42570395 100644
--- a/playground/rsc-vite-framework/package.json
+++ b/playground/rsc-vite-framework/package.json
@@ -29,7 +29,7 @@
"vite": "^8.0.0"
},
"dependencies": {
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"express": "^4.22.2",
"react": "catalog:",
diff --git a/playground/rsc-vite/package.json b/playground/rsc-vite/package.json
index 7481e8b038..bc25befa3e 100644
--- a/playground/rsc-vite/package.json
+++ b/playground/rsc-vite/package.json
@@ -21,7 +21,7 @@
"vite": "^6.3.0"
},
"dependencies": {
- "@remix-run/node-fetch-server": "^0.13.3",
+ "@remix-run/node-fetch-server": "^0.14.1",
"compression": "^1.8.1",
"express": "^4.22.2",
"react": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2dd8471dcd..6e46eb97c0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -288,8 +288,8 @@ importers:
integration/helpers/rsc-vite:
dependencies:
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -343,8 +343,8 @@ importers:
specifier: workspace:*
version: link:../../../packages/react-router-serve
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -776,8 +776,8 @@ importers:
specifier: workspace:*
version: link:../react-router-node
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
babel-dead-code-elimination:
specifier: ^1.0.12
version: 1.0.12
@@ -965,8 +965,8 @@ importers:
packages/react-router-node:
dependencies:
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
devDependencies:
'@types/node':
specifier: ^22.19.19
@@ -1011,8 +1011,8 @@ importers:
specifier: workspace:*
version: link:../react-router-node
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -1379,8 +1379,8 @@ importers:
playground/rsc-vite:
dependencies:
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -1431,8 +1431,8 @@ importers:
playground/rsc-vite-7-framework:
dependencies:
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -1501,8 +1501,8 @@ importers:
playground/rsc-vite-framework:
dependencies:
'@remix-run/node-fetch-server':
- specifier: ^0.13.3
- version: 0.13.3
+ specifier: ^0.14.1
+ version: 0.14.1
compression:
specifier: ^1.8.1
version: 1.8.1
@@ -3797,8 +3797,8 @@ packages:
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
- '@remix-run/node-fetch-server@0.13.3':
- resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==}
+ '@remix-run/node-fetch-server@0.14.1':
+ resolution: {integrity: sha512-pKenF5ysIN5lDCnCyvoUxDhKP0tfnGagvGbZh01xxHG6DJXSIXWVC9NpLSDGjxcxd1CKdCOKuZFleXBF3pChIg==}
'@rolldown/binding-android-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==}
@@ -11291,7 +11291,7 @@ snapshots:
dependencies:
quansync: 1.0.0
- '@remix-run/node-fetch-server@0.13.3': {}
+ '@remix-run/node-fetch-server@0.14.1': {}
'@rolldown/binding-android-arm64@1.0.0-rc.11':
optional: true