diff --git a/.changeset/normalize-form-method.md b/.changeset/normalize-form-method.md new file mode 100644 index 0000000000..e781bf8a20 --- /dev/null +++ b/.changeset/normalize-form-method.md @@ -0,0 +1,7 @@ +--- +"react-router": minor +"react-router-dom": minor +"@remix-run/router": minor +--- + +Add `future.v7_normalizeFormMethod` flag to normalize exposed `useNavigation().formMethod` and `useFetcher().formMethod` fields as uppercase HTTP methods to align with the `fetch()` behavior diff --git a/docs/routers/create-browser-router.md b/docs/routers/create-browser-router.md index 433354249e..6126de6499 100644 --- a/docs/routers/create-browser-router.md +++ b/docs/routers/create-browser-router.md @@ -47,6 +47,8 @@ function createBrowserRouter( routes: RouteObject[], opts?: { basename?: string; + future?: FutureConfig; + hydrationData?: HydrationState; window?: Window; } ): RemixRouter; diff --git a/docs/routers/create-memory-router.md b/docs/routers/create-memory-router.md index 2b5c24148a..60103b2a83 100644 --- a/docs/routers/create-memory-router.md +++ b/docs/routers/create-memory-router.md @@ -52,9 +52,10 @@ function createMemoryRouter( routes: RouteObject[], opts?: { basename?: string; + future?: FutureConfig; + hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; - window?: Window; } ): RemixRouter; ``` diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index a87ff65715..6a01100aff 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -1,8 +1,8 @@ -import type { FormEncType, FormMethod } from "@remix-run/router"; +import type { FormEncType, HTMLFormMethod } from "@remix-run/router"; import type { RelativeRoutingType } from "react-router"; -export const defaultMethod = "get"; -const defaultEncType = "application/x-www-form-urlencoded"; +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"; @@ -110,7 +110,7 @@ export interface SubmitOptions { * The HTTP method used to submit the form. Overrides `
`. * Defaults to "GET". */ - method?: FormMethod; + method?: HTMLFormMethod; /** * The action URL path used to submit the form. Overrides ``. diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 3bb811ca74..dcfa2b8f7d 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -30,11 +30,14 @@ import type { Fetcher, FormEncType, FormMethod, + FutureConfig, GetScrollRestorationKeyFunction, HashHistory, History, + HTMLFormMethod, HydrationState, Router as RemixRouter, + V7_FormMethod, } from "@remix-run/router"; import { createRouter, @@ -71,6 +74,7 @@ export type { ParamKeyValuePair, SubmitOptions, URLSearchParamsInit, + V7_FormMethod, }; export { createSearchParams }; @@ -199,16 +203,20 @@ declare global { //#region Routers //////////////////////////////////////////////////////////////////////////////// +interface DOMRouterOpts { + basename?: string; + future?: FutureConfig; + hydrationData?: HydrationState; + window?: Window; +} + export function createBrowserRouter( routes: RouteObject[], - opts?: { - basename?: string; - hydrationData?: HydrationState; - window?: Window; - } + opts?: DOMRouterOpts ): RemixRouter { return createRouter({ basename: opts?.basename, + future: opts?.future, history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, @@ -218,14 +226,11 @@ export function createBrowserRouter( export function createHashRouter( routes: RouteObject[], - opts?: { - basename?: string; - hydrationData?: HydrationState; - window?: Window; - } + opts?: DOMRouterOpts ): RemixRouter { return createRouter({ basename: opts?.basename, + future: opts?.future, history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, @@ -611,7 +616,7 @@ export interface FormProps extends React.FormHTMLAttributes { * The HTTP verb to use when the form is submit. Supports "get", "post", * "put", "delete", "patch". */ - method?: FormMethod; + method?: HTMLFormMethod; /** * Normal `` but supports React Router's relative paths. @@ -696,7 +701,7 @@ const FormImpl = React.forwardRef( forwardedRef ) => { let submit = useSubmitImpl(fetcherKey, routeId); - let formMethod: FormMethod = + let formMethod: HTMLFormMethod = method.toLowerCase() === "get" ? "get" : "post"; let formAction = useFormAction(action, { relative }); let submitHandler: React.FormEventHandler = (event) => { @@ -708,7 +713,7 @@ const FormImpl = React.forwardRef( .submitter as HTMLFormSubmitter | null; let submitMethod = - (submitter?.getAttribute("formmethod") as FormMethod | undefined) || + (submitter?.getAttribute("formmethod") as HTMLFormMethod | undefined) || method; submit(submitter || event.currentTarget, { @@ -965,7 +970,7 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction { replace: options.replace, preventScrollReset: options.preventScrollReset, formData, - formMethod: method as FormMethod, + formMethod: method as HTMLFormMethod, formEncType: encType as FormEncType, }; if (fetcherKey) { diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index a0d2ca597a..a48c0c6492 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -21,6 +21,7 @@ import type { To, InitialEntry, LazyRouteFunction, + FutureConfig, } from "@remix-run/router"; import { AbortedDeferredError, @@ -233,6 +234,7 @@ export function createMemoryRouter( routes: RouteObject[], opts?: { basename?: string; + future?: FutureConfig; hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; @@ -240,6 +242,7 @@ export function createMemoryRouter( ): RemixRouter { return createRouter({ basename: opts?.basename, + future: opts?.future, history: createMemoryHistory({ initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index fe9ed287e4..91b5f3978f 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -10,6 +10,7 @@ import type { RouterNavigateOptions, StaticHandler, StaticHandlerContext, + FutureConfig, } from "../index"; import { createMemoryHistory, @@ -289,6 +290,7 @@ type SetupOpts = { initialEntries?: InitialEntry[]; initialIndex?: number; hydrationData?: HydrationState; + future?: FutureConfig; }; function setup({ @@ -297,6 +299,7 @@ function setup({ initialEntries, initialIndex, hydrationData, + future, }: SetupOpts) { let guid = 0; // Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId. @@ -424,6 +427,7 @@ function setup({ history, routes: enhanceRoutes(routes), hydrationData, + future, }).initialize(); function getRouteHelpers( @@ -3620,6 +3624,117 @@ describe("a router", () => { "childIndex", ]); }); + + describe("formMethod casing", () => { + it("normalizes to lowercase in v6", async () => { + let t = setup({ + routes: [ + { + id: "root", + path: "/", + children: [ + { + id: "child", + path: "child", + loader: true, + action: true, + }, + ], + }, + ], + }); + let A = await t.navigate("/child", { + formMethod: "get", + formData: createFormData({}), + }); + expect(t.router.state.navigation.formMethod).toBe("get"); + await A.loaders.child.resolve("LOADER"); + expect(t.router.state.navigation.formMethod).toBeUndefined(); + await t.router.navigate("/"); + + let B = await t.navigate("/child", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(t.router.state.navigation.formMethod).toBe("post"); + await B.actions.child.resolve("ACTION"); + await B.loaders.child.resolve("LOADER"); + expect(t.router.state.navigation.formMethod).toBeUndefined(); + await t.router.navigate("/"); + + let C = await t.fetch("/child", "key", { + formMethod: "GET", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get("key")?.formMethod).toBe("get"); + await C.loaders.child.resolve("LOADER FETCH"); + expect(t.router.state.fetchers.get("key")?.formMethod).toBeUndefined(); + + let D = await t.fetch("/child", "key", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get("key")?.formMethod).toBe("post"); + await D.actions.child.resolve("ACTION FETCH"); + expect(t.router.state.fetchers.get("key")?.formMethod).toBeUndefined(); + }); + + it("normalizes to uppercase in v7 via v7_normalizeFormMethod", async () => { + let t = setup({ + routes: [ + { + id: "root", + path: "/", + children: [ + { + id: "child", + path: "child", + loader: true, + action: true, + }, + ], + }, + ], + future: { + v7_normalizeFormMethod: true, + }, + }); + let A = await t.navigate("/child", { + formMethod: "get", + formData: createFormData({}), + }); + expect(t.router.state.navigation.formMethod).toBe("GET"); + await A.loaders.child.resolve("LOADER"); + expect(t.router.state.navigation.formMethod).toBeUndefined(); + await t.router.navigate("/"); + + let B = await t.navigate("/child", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(t.router.state.navigation.formMethod).toBe("POST"); + await B.actions.child.resolve("ACTION"); + await B.loaders.child.resolve("LOADER"); + expect(t.router.state.navigation.formMethod).toBeUndefined(); + await t.router.navigate("/"); + + let C = await t.fetch("/child", "key", { + formMethod: "GET", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get("key")?.formMethod).toBe("GET"); + await C.loaders.child.resolve("LOADER FETCH"); + expect(t.router.state.fetchers.get("key")?.formMethod).toBeUndefined(); + + let D = await t.fetch("/child", "key", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get("key")?.formMethod).toBe("POST"); + await D.actions.child.resolve("ACTION FETCH"); + expect(t.router.state.fetchers.get("key")?.formMethod).toBeUndefined(); + }); + }); }); describe("action errors", () => { diff --git a/packages/router/index.ts b/packages/router/index.ts index 542c0b1211..3d4fea9620 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -13,6 +13,7 @@ export type { TrackedPromise, FormEncType, FormMethod, + HTMLFormMethod, JsonFunction, LoaderFunction, LoaderFunctionArgs, @@ -22,7 +23,7 @@ export type { PathPattern, RedirectFunction, ShouldRevalidateFunction, - Submission, + V7_FormMethod, } from "./utils"; export { diff --git a/packages/router/router.ts b/packages/router/router.ts index bd51457eab..c4f31e5dd0 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -22,12 +22,15 @@ import type { Submission, SuccessResult, AgnosticRouteMatch, - MutationFormMethod, ShouldRevalidateFunction, RouteManifest, ImmutableRouteKey, ActionFunction, LoaderFunction, + V7_MutationFormMethod, + V7_FormMethod, + HTMLFormMethod, + MutationFormMethod, } from "./utils"; import { DeferredData, @@ -326,15 +329,23 @@ export type HydrationState = Partial< Pick >; +/** + * Future flags to toggle new feature behavior + */ +export interface FutureConfig { + v7_normalizeFormMethod: boolean; +} + /** * Initialization options for createRouter */ export interface RouterInit { - basename?: string; routes: AgnosticRouteObject[]; history: History; - hydrationData?: HydrationState; + basename?: string; detectErrorBoundary?: DetectErrorBoundaryFunction; + future?: FutureConfig; + hydrationData?: HydrationState; } /** @@ -415,7 +426,7 @@ type SubmissionNavigateOptions = { replace?: boolean; state?: any; preventScrollReset?: boolean; - formMethod?: FormMethod; + formMethod?: HTMLFormMethod; formEncType?: FormEncType; formData: FormData; }; @@ -449,7 +460,7 @@ export type NavigationStates = { Loading: { state: "loading"; location: Location; - formMethod: FormMethod | undefined; + formMethod: FormMethod | V7_FormMethod | undefined; formAction: string | undefined; formEncType: FormEncType | undefined; formData: FormData | undefined; @@ -457,7 +468,7 @@ export type NavigationStates = { Submitting: { state: "submitting"; location: Location; - formMethod: FormMethod; + formMethod: FormMethod | V7_FormMethod; formAction: string; formEncType: FormEncType; formData: FormData; @@ -483,7 +494,7 @@ type FetcherStates = { }; Loading: { state: "loading"; - formMethod: FormMethod | undefined; + formMethod: FormMethod | V7_FormMethod | undefined; formAction: string | undefined; formEncType: FormEncType | undefined; formData: FormData | undefined; @@ -492,7 +503,7 @@ type FetcherStates = { }; Submitting: { state: "submitting"; - formMethod: FormMethod; + formMethod: FormMethod | V7_FormMethod; formAction: string; formEncType: FormEncType; formData: FormData; @@ -676,6 +687,11 @@ export function createRouter(init: RouterInit): Router { manifest ); let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; + // Config driven behavior flags + let future: FutureConfig = { + v7_normalizeFormMethod: false, + ...init.future, + }; // Cleanup function for history let unlistenHistory: (() => void) | null = null; // Externally-provided functions to call on all state changes @@ -1013,7 +1029,11 @@ export function createRouter(init: RouterInit): Router { return; } - let { path, submission, error } = normalizeNavigateOptions(to, opts); + let { path, submission, error } = normalizeNavigateOptions( + to, + future, + opts + ); let currentLocation = state.location; let nextLocation = createLocation(state.location, path, opts && opts.state); @@ -1568,7 +1588,12 @@ export function createRouter(init: RouterInit): Router { return; } - let { path, submission } = normalizeNavigateOptions(href, opts, true); + let { path, submission } = normalizeNavigateOptions( + href, + future, + opts, + true + ); let match = getTargetMatch(matches, path); pendingPreventScrollReset = (opts && opts.preventScrollReset) === true; @@ -2441,12 +2466,12 @@ export function createStaticHandler( { requestContext }: { requestContext?: unknown } = {} ): Promise { let url = new URL(request.url); - let method = request.method.toLowerCase(); + let method = request.method; let location = createLocation("", createPath(url), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); // SSR supports HEAD requests while SPA doesn't - if (!isValidMethod(method) && method !== "head") { + if (!isValidMethod(method) && method !== "HEAD") { let error = getInternalRouterError(405, { method }); let { matches: methodNotAllowedMatches, route } = getShortCircuitMatches(dataRoutes); @@ -2523,12 +2548,12 @@ export function createStaticHandler( }: { requestContext?: unknown; routeId?: string } = {} ): Promise { let url = new URL(request.url); - let method = request.method.toLowerCase(); + let method = request.method; let location = createLocation("", createPath(url), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); // SSR supports HEAD requests while SPA doesn't - if (!isValidMethod(method) && method !== "head" && method !== "options") { + if (!isValidMethod(method) && method !== "HEAD" && method !== "OPTIONS") { throw getInternalRouterError(405, { method }); } else if (!matches) { throw getInternalRouterError(404, { pathname: location.pathname }); @@ -2923,6 +2948,7 @@ function isSubmissionNavigation( // URLSearchParams so they behave identically to links with query params function normalizeNavigateOptions( to: To, + future: FutureConfig, opts?: RouterNavigateOptions, isFetcher = false ): { @@ -2947,8 +2973,11 @@ function normalizeNavigateOptions( // Create a Submission on non-GET navigations let submission: Submission | undefined; if (opts.formData) { + let formMethod = opts.formMethod || "get"; submission = { - formMethod: opts.formMethod || "get", + formMethod: future.v7_normalizeFormMethod + ? (formMethod.toUpperCase() as V7_FormMethod) + : (formMethod.toLowerCase() as FormMethod), formAction: stripHashFromPath(path), formEncType: (opts && opts.formEncType) || "application/x-www-form-urlencoded", @@ -3462,7 +3491,7 @@ function createClientSideRequest( if (submission && isMutationMethod(submission.formMethod)) { let { formMethod, formEncType, formData } = submission; - init.method = formMethod.toUpperCase(); + init.method = formMethod; init.body = formEncType === "application/x-www-form-urlencoded" ? convertFormDataToSearchParams(formData) @@ -3831,12 +3860,14 @@ function isQueryRouteResponse(obj: any): obj is QueryRouteResponse { ); } -function isValidMethod(method: string): method is FormMethod { - return validRequestMethods.has(method as FormMethod); +function isValidMethod(method: string): method is FormMethod | V7_FormMethod { + return validRequestMethods.has(method.toLowerCase() as FormMethod); } -function isMutationMethod(method?: string): method is MutationFormMethod { - return validMutationMethods.has(method as MutationFormMethod); +function isMutationMethod( + method: string +): method is MutationFormMethod | V7_MutationFormMethod { + return validMutationMethods.has(method.toLowerCase() as MutationFormMethod); } async function resolveDeferredResults( diff --git a/packages/router/utils.ts b/packages/router/utils.ts index fd9d1cbea5..4851168d21 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -63,8 +63,28 @@ export type DataResult = | RedirectResult | ErrorResult; -export type MutationFormMethod = "post" | "put" | "patch" | "delete"; -export type FormMethod = "get" | MutationFormMethod; +type LowerCaseFormMethod = "get" | "post" | "put" | "patch" | "delete"; +type UpperCaseFormMethod = Uppercase; + +/** + * Users can specify either lowercase or uppercase form methods on , + * useSubmit(), , etc. + */ +export type HTMLFormMethod = LowerCaseFormMethod | UpperCaseFormMethod; + +/** + * Active navigation/fetcher form methods are exposed in lowercase on the + * RouterState + */ +export type FormMethod = LowerCaseFormMethod; +export type MutationFormMethod = Exclude; + +/** + * In v7, active navigation/fetcher form methods are exposed in uppercase on the + * RouterState. This is to align with the normalization done via fetch(). + */ +export type V7_FormMethod = UpperCaseFormMethod; +export type V7_MutationFormMethod = Exclude; export type FormEncType = | "application/x-www-form-urlencoded" @@ -76,7 +96,7 @@ export type FormEncType = * external consumption */ export interface Submission { - formMethod: FormMethod; + formMethod: FormMethod | V7_FormMethod; formAction: string; formEncType: FormEncType; formData: FormData;