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 `
`. + * Defaults to "GET". + */ + method?: HTMLFormMethod; + + /** + * The action URL path used to submit the form. Overrides ``. + * Defaults to the path of the current route. + */ + action?: string; + + /** + * The encoding used to submit the form. Overrides ``. + * Defaults to "application/x-www-form-urlencoded". + */ + encType?: FormEncType; + + /** + * Determines whether the form action is relative to the route hierarchy or + * the pathname. Use this if you want to opt out of navigating the route + * hierarchy and want to instead route based on /-delimited URL segments + */ + relative?: RelativeRoutingType; + + /** + * In browser-based environments, prevent resetting scroll after this + * navigation when using the component + */ + preventScrollReset?: boolean; + + /** + * Enable flushSync for this submission's state updates + */ + unstable_flushSync?: boolean; +} + +/** + * Submit options available to fetchers + */ +export interface FetcherSubmitOptions extends SharedSubmitOptions {} + +/** + * Submit options available to navigations + */ +export interface SubmitOptions extends FetcherSubmitOptions { + /** + * Set `true` to replace the current entry in the browser's history stack + * instead of creating a new one (i.e. stay on "the same page"). Defaults + * to `false`. + */ + replace?: boolean; + + /** + * State object to add to the history stack entry for this navigation + */ + state?: any; + + /** + * Indicate a specific fetcherKey to use when using navigate=false + */ + fetcherKey?: string; + + /** + * navigate=false will use a fetcher instead of a navigation + */ + navigate?: boolean; + + /** + * Enable view transitions on this submission navigation + */ + unstable_viewTransition?: boolean; +} + +const supportedFormEncTypes: Set = new Set([ + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", +]); + +function getFormEncType(encType: string | null) { + if (encType != null && !supportedFormEncTypes.has(encType as FormEncType)) { + warning( + false, + `"${encType}" is not a valid \`encType\` for \`\`/\`\` ` + + `and will default to "${defaultEncType}"` + ); + + return null; + } + return encType; +} + +export function getFormSubmissionInfo( + target: SubmitTarget, + basename: string +): { + action: string | null; + method: string; + encType: string; + formData: FormData | undefined; + body: any; +} { + let method: string; + let action: string | null; + let encType: string; + let formData: FormData | undefined; + let body: any; + + if (isFormElement(target)) { + // When grabbing the action from the element, it will have had the basename + // prefixed to ensure non-JS scenarios work, so strip it since we'll + // re-prefix in the router + let attr = target.getAttribute("action"); + action = attr ? stripBasename(attr, basename) : null; + method = target.getAttribute("method") || defaultMethod; + encType = getFormEncType(target.getAttribute("enctype")) || defaultEncType; + + formData = new FormData(target); + } else if ( + isButtonElement(target) || + (isInputElement(target) && + (target.type === "submit" || target.type === "image")) + ) { + let form = target.form; + + if (form == null) { + throw new Error( + `Cannot submit a