diff --git a/.changeset/warm-melons-shop.md b/.changeset/warm-melons-shop.md
new file mode 100644
index 0000000000..4d73f59df9
--- /dev/null
+++ b/.changeset/warm-melons-shop.md
@@ -0,0 +1,5 @@
+---
+"react-router": minor
+---
+
+Stabilize `unstable_patchRoutesOnNavigation`
diff --git a/integration/routes-config-test.ts b/integration/routes-config-test.ts
index 070f5ae213..485f805d07 100644
--- a/integration/routes-config-test.ts
+++ b/integration/routes-config-test.ts
@@ -224,4 +224,28 @@ test.describe("routes config", () => {
);
}).toPass();
});
+
+ test("supports absolute route file paths", async ({ page, dev }) => {
+ let files: Files = async ({ port }) => ({
+ "vite.config.js": await viteConfig.basic({ port }),
+ "app/routes.ts": js`
+ import path from "node:path";
+ import { type RouteConfig } from "@react-router/dev/routes";
+
+ export const routes: RouteConfig = [
+ {
+ file: path.resolve(import.meta.dirname, "test-route.tsx"),
+ index: true,
+ },
+ ];
+ `,
+ "app/test-route.tsx": `
+ export default () =>
Test route
+ `,
+ });
+ let { port } = await dev(files);
+
+ await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" });
+ await expect(page.locator("[data-test-route]")).toHaveText("Test route");
+ });
});
diff --git a/packages/react-router-dev/__tests__/routes-config-test.ts b/packages/react-router-dev/__tests__/routes-config-test.ts
index 3b13bf8bd4..52e049ad80 100644
--- a/packages/react-router-dev/__tests__/routes-config-test.ts
+++ b/packages/react-router-dev/__tests__/routes-config-test.ts
@@ -1,4 +1,4 @@
-import { route, layout, index } from "../config/routes";
+import { route, layout, index, relative } from "../config/routes";
describe("routes config", () => {
describe("route helpers", () => {
@@ -196,5 +196,69 @@ describe("routes config", () => {
`);
});
});
+
+ describe("relative", () => {
+ it("supports relative routes", () => {
+ let { route } = relative("/path/to/dirname");
+ expect(
+ route("parent", "nested/parent.tsx", [
+ route("child", "nested/child.tsx", { id: "child" }),
+ ])
+ ).toMatchInlineSnapshot(`
+ {
+ "children": [
+ {
+ "children": undefined,
+ "file": "/path/to/dirname/nested/child.tsx",
+ "id": "child",
+ "path": "child",
+ },
+ ],
+ "file": "/path/to/dirname/nested/parent.tsx",
+ "path": "parent",
+ }
+ `);
+ });
+
+ it("supports relative index routes", () => {
+ let { index } = relative("/path/to/dirname");
+ expect([
+ index("nested/without-options.tsx"),
+ index("nested/with-options.tsx", { id: "with-options" }),
+ ]).toMatchInlineSnapshot(`
+ [
+ {
+ "file": "/path/to/dirname/nested/without-options.tsx",
+ "index": true,
+ },
+ {
+ "file": "/path/to/dirname/nested/with-options.tsx",
+ "id": "with-options",
+ "index": true,
+ },
+ ]
+ `);
+ });
+
+ it("supports relative layout routes", () => {
+ let { layout } = relative("/path/to/dirname");
+ expect(
+ layout("nested/parent.tsx", [
+ layout("nested/child.tsx", { id: "child" }),
+ ])
+ ).toMatchInlineSnapshot(`
+ {
+ "children": [
+ {
+ "children": undefined,
+ "file": "/path/to/dirname/nested/child.tsx",
+ "id": "child",
+ },
+ ],
+ "file": "/path/to/dirname/nested/parent.tsx",
+ }
+ `);
+ });
+ });
});
});
diff --git a/packages/react-router-dev/config/routes.ts b/packages/react-router-dev/config/routes.ts
index d122206f40..08081cb998 100644
--- a/packages/react-router-dev/config/routes.ts
+++ b/packages/react-router-dev/config/routes.ts
@@ -1,4 +1,4 @@
-import * as path from "node:path";
+import { resolve, win32 } from "node:path";
import pick from "lodash/pick";
import invariant from "../invariant";
@@ -92,11 +92,6 @@ export interface RouteConfigEntry {
type CreateRoutePath = string | null | undefined;
-type RequireAtLeastOne = {
- [K in keyof T]-?: Required> &
- Partial>>;
-}[keyof T];
-
const createConfigRouteOptionKeys = [
"id",
"index",
@@ -114,7 +109,7 @@ function createRoute(
function createRoute(
path: CreateRoutePath,
file: string,
- options: RequireAtLeastOne,
+ options: CreateRouteOptions,
children?: RouteConfigEntry[]
): RouteConfigEntry;
function createRoute(
@@ -148,7 +143,7 @@ type CreateIndexOptions = Pick<
>;
function createIndex(
file: string,
- options?: RequireAtLeastOne
+ options?: CreateIndexOptions
): RouteConfigEntry {
return {
file,
@@ -170,7 +165,7 @@ function createLayout(
): RouteConfigEntry;
function createLayout(
file: string,
- options: RequireAtLeastOne,
+ options: CreateLayoutOptions,
children?: RouteConfigEntry[]
): RouteConfigEntry;
function createLayout(
@@ -196,6 +191,24 @@ function createLayout(
export const route = createRoute;
export const index = createIndex;
export const layout = createLayout;
+type RouteHelpers = {
+ route: typeof route;
+ index: typeof index;
+ layout: typeof layout;
+};
+export function relative(directory: string): RouteHelpers {
+ return {
+ route: (path, file, ...rest) => {
+ return route(path, resolve(directory, file), ...(rest as any));
+ },
+ index: (file, ...rest) => {
+ return index(resolve(directory, file), ...(rest as any));
+ },
+ layout: (file, ...rest) => {
+ return layout(resolve(directory, file), ...(rest as any));
+ },
+ };
+}
export function configRoutesToRouteManifest(
routes: RouteConfigEntry[],
@@ -240,7 +253,7 @@ function createRouteId(file: string) {
}
function normalizeSlashes(file: string) {
- return file.split(path.win32.sep).join("/");
+ return file.split(win32.sep).join("/");
}
function stripFileExtension(file: string) {
diff --git a/packages/react-router-dev/routes.ts b/packages/react-router-dev/routes.ts
index 7d39f337e5..96140d6f72 100644
--- a/packages/react-router-dev/routes.ts
+++ b/packages/react-router-dev/routes.ts
@@ -1,3 +1,9 @@
export type { RouteConfig, RouteConfigEntry } from "./config/routes";
-export { route, index, layout, getAppDirectory } from "./config/routes";
+export {
+ route,
+ index,
+ layout,
+ relative,
+ getAppDirectory,
+} from "./config/routes";
diff --git a/packages/react-router-dom-v5-compat/react-router-dom/dom.ts b/packages/react-router-dom-v5-compat/react-router-dom/dom.ts
new file mode 100644
index 0000000000..ca2ac9a767
--- /dev/null
+++ b/packages/react-router-dom-v5-compat/react-router-dom/dom.ts
@@ -0,0 +1,342 @@
+import type {
+ FormEncType,
+ HTMLFormMethod,
+ RelativeRoutingType,
+} from "@remix-run/router";
+import { stripBasename, UNSAFE_warning as warning } from "@remix-run/router";
+
+export const defaultMethod: HTMLFormMethod = "get";
+const defaultEncType: FormEncType = "application/x-www-form-urlencoded";
+
+export function isHtmlElement(object: any): object is HTMLElement {
+ return object != null && typeof object.tagName === "string";
+}
+
+export function isButtonElement(object: any): object is HTMLButtonElement {
+ return isHtmlElement(object) && object.tagName.toLowerCase() === "button";
+}
+
+export function isFormElement(object: any): object is HTMLFormElement {
+ return isHtmlElement(object) && object.tagName.toLowerCase() === "form";
+}
+
+export function isInputElement(object: any): object is HTMLInputElement {
+ return isHtmlElement(object) && object.tagName.toLowerCase() === "input";
+}
+
+type LimitedMouseEvent = Pick<
+ MouseEvent,
+ "button" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey"
+>;
+
+function isModifiedEvent(event: LimitedMouseEvent) {
+ return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
+}
+
+export function shouldProcessLinkClick(
+ event: LimitedMouseEvent,
+ target?: string
+) {
+ return (
+ event.button === 0 && // Ignore everything but left clicks
+ (!target || target === "_self") && // Let browser handle "target=_blank" etc.
+ !isModifiedEvent(event) // Ignore clicks with modifier keys
+ );
+}
+
+export type ParamKeyValuePair = [string, string];
+
+export type URLSearchParamsInit =
+ | string
+ | ParamKeyValuePair[]
+ | Record
+ | URLSearchParams;
+
+/**
+ * Creates a URLSearchParams object using the given initializer.
+ *
+ * This is identical to `new URLSearchParams(init)` except it also
+ * supports arrays as values in the object form of the initializer
+ * instead of just strings. This is convenient when you need multiple
+ * values for a given key, but don't want to use an array initializer.
+ *
+ * For example, instead of:
+ *
+ * let searchParams = new URLSearchParams([
+ * ['sort', 'name'],
+ * ['sort', 'price']
+ * ]);
+ *
+ * you can do:
+ *
+ * let searchParams = createSearchParams({
+ * sort: ['name', 'price']
+ * });
+ */
+export function createSearchParams(
+ init: URLSearchParamsInit = ""
+): URLSearchParams {
+ return new URLSearchParams(
+ typeof init === "string" ||
+ Array.isArray(init) ||
+ init instanceof URLSearchParams
+ ? init
+ : Object.keys(init).reduce((memo, key) => {
+ let value = init[key];
+ return memo.concat(
+ Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
+ );
+ }, [] as ParamKeyValuePair[])
+ );
+}
+
+export function getSearchParamsForLocation(
+ locationSearch: string,
+ defaultSearchParams: URLSearchParams | null
+) {
+ let searchParams = createSearchParams(locationSearch);
+
+ if (defaultSearchParams) {
+ // Use `defaultSearchParams.forEach(...)` here instead of iterating of
+ // `defaultSearchParams.keys()` to work-around a bug in Firefox related to
+ // web extensions. Relevant Bugzilla tickets:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1414602
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1023984
+ defaultSearchParams.forEach((_, key) => {
+ if (!searchParams.has(key)) {
+ defaultSearchParams.getAll(key).forEach((value) => {
+ searchParams.append(key, value);
+ });
+ }
+ });
+ }
+
+ return searchParams;
+}
+
+// Thanks https://github.com/sindresorhus/type-fest!
+type JsonObject = { [Key in string]: JsonValue } & {
+ [Key in string]?: JsonValue | undefined;
+};
+type JsonArray = JsonValue[] | readonly JsonValue[];
+type JsonPrimitive = string | number | boolean | null;
+type JsonValue = JsonPrimitive | JsonObject | JsonArray;
+
+export type SubmitTarget =
+ | HTMLFormElement
+ | HTMLButtonElement
+ | HTMLInputElement
+ | FormData
+ | URLSearchParams
+ | JsonValue
+ | null;
+
+// One-time check for submitter support
+let _formDataSupportsSubmitter: boolean | null = null;
+
+function isFormDataSubmitterSupported() {
+ if (_formDataSupportsSubmitter === null) {
+ try {
+ new FormData(
+ document.createElement("form"),
+ // @ts-expect-error if FormData supports the submitter parameter, this will throw
+ 0
+ );
+ _formDataSupportsSubmitter = false;
+ } catch (e) {
+ _formDataSupportsSubmitter = true;
+ }
+ }
+ return _formDataSupportsSubmitter;
+}
+
+/**
+ * Submit options shared by both navigations and fetchers
+ */
+interface SharedSubmitOptions {
+ /**
+ * The HTTP method used to submit the form. Overrides `