diff --git a/.changeset/normalize-form-method.md b/.changeset/normalize-form-method.md
new file mode 100644
index 00000000000..9745c6d1e7c
--- /dev/null
+++ b/.changeset/normalize-form-method.md
@@ -0,0 +1,8 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+"@remix-run/testing": minor
+---
+
+Add `future.v2_normalizeFormMethod` flag to normalize exposed `useNavigation().formMethod` as an uppercase HTTP method to align with the `fetch()` behavior
diff --git a/integration/navigation-state-v2-test.ts b/integration/navigation-state-v2-test.ts
new file mode 100644
index 00000000000..e87053a636f
--- /dev/null
+++ b/integration/navigation-state-v2-test.ts
@@ -0,0 +1,463 @@
+import { test, expect } from "@playwright/test";
+
+import { createAppFixture, createFixture, js } from "./helpers/create-fixture";
+import type { Fixture, AppFixture } from "./helpers/create-fixture";
+import { PlaywrightFixture } from "./helpers/playwright-fixture";
+
+const STATES = {
+ NORMAL_LOAD: "normal-load",
+ LOADING_REDIRECT: "loading-redirect",
+ SUBMITTING_LOADER: "submitting-loader",
+ SUBMITTING_LOADER_REDIRECT: "submitting-loader-redirect",
+ SUBMITTING_ACTION: "submitting-action",
+ SUBMITTING_ACTION_REDIRECT: "submitting-action-redirect",
+ FETCHER_REDIRECT: "fetcher-redirect",
+} as const;
+
+const IDLE_STATE = {
+ state: "idle",
+};
+
+// These are a copy of the tests from navigation-state-test to test with
+// future.v2_normalizeFormMethod enabled. Once we're in v2, we can delete
+// the other file and keep this one.
+test.describe("navigation states", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ future: {
+ v2_normalizeFormMethod: true,
+ },
+ files: {
+ "app/root.jsx": js`
+ import { useMemo, useRef } from "react";
+ import { Outlet, Scripts, useNavigation } from "@remix-run/react";
+ export default function() {
+ const navigation = useNavigation();
+ const navigationsRef = useRef();
+ const navigations = useMemo(() => {
+ const savedNavigations = navigationsRef.current || [];
+ savedNavigations.push(navigation);
+ navigationsRef.current = savedNavigations;
+ return savedNavigations;
+ }, [navigation]);
+ return (
+
+
Test
+
+
+ {navigation.state != "idle" && (
+ Loading...
+ )}
+
+
+ {JSON.stringify(navigations, null, 2)}
+
+
+
+
+
+ );
+ }
+ `,
+ "app/routes/index.jsx": js`
+ import { Form, Link, useFetcher } from "@remix-run/react";
+ export function loader() { return null; }
+ export default function() {
+ const fetcher = useFetcher();
+ return (
+
+ -
+
+ ${STATES.NORMAL_LOAD}
+
+
+ -
+
+ ${STATES.LOADING_REDIRECT}
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.NORMAL_LOAD}.jsx`]: js`
+ export default function() {
+ return (
+
+ ${STATES.NORMAL_LOAD}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.LOADING_REDIRECT}.jsx`]: js`
+ import { redirect } from "@remix-run/node";
+ export function loader() {
+ return redirect("/?redirected");
+ }
+ export default function() {
+ return (
+
+ ${STATES.LOADING_REDIRECT}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.SUBMITTING_LOADER}.jsx`]: js`
+ export default function() {
+ return (
+
+ ${STATES.SUBMITTING_LOADER}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.SUBMITTING_LOADER_REDIRECT}.jsx`]: js`
+ import { redirect } from "@remix-run/node";
+ export function loader() {
+ return redirect("/?redirected");
+ }
+ export default function() {
+ return (
+
+ ${STATES.SUBMITTING_LOADER_REDIRECT}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.SUBMITTING_ACTION}.jsx`]: js`
+ export function loader() { return null; }
+ export function action() { return null; }
+ export default function() {
+ return (
+
+ ${STATES.SUBMITTING_ACTION}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.SUBMITTING_ACTION_REDIRECT}.jsx`]: js`
+ import { redirect } from "@remix-run/node";
+ export function action() {
+ return redirect("/?redirected");
+ }
+ export default function() {
+ return (
+
+ ${STATES.SUBMITTING_ACTION_REDIRECT}
+
+ );
+ }
+ `,
+ [`app/routes/${STATES.FETCHER_REDIRECT}.jsx`]: js`
+ import { redirect } from "@remix-run/node";
+ export function action() {
+ return redirect("/?redirected");
+ }
+ `,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => {
+ appFixture.close();
+ });
+
+ test("normal load (Loading)", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickLink(`/${STATES.NORMAL_LOAD}`);
+ await page.waitForSelector(`#${STATES.NORMAL_LOAD}`);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "loading",
+ location: {
+ pathname: `/${STATES.NORMAL_LOAD}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("normal redirect (LoadingRedirect)", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickLink(`/${STATES.LOADING_REDIRECT}`);
+ await page.waitForURL(/\?redirected/);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "loading",
+ location: {
+ pathname: `/${STATES.LOADING_REDIRECT}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ },
+ {
+ state: "loading",
+ location: {
+ pathname: "/",
+ search: "?redirected",
+ hash: "",
+ state: {
+ _isRedirect: true,
+ },
+ key: expect.any(String),
+ },
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("loader submission (SubmittingLoader)", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER}`);
+ await page.waitForSelector(`#${STATES.SUBMITTING_LOADER}`);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "loading",
+ location: {
+ pathname: `/${STATES.SUBMITTING_LOADER}`,
+ search: "?key=value",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ formMethod: "GET",
+ formAction: `/${STATES.SUBMITTING_LOADER}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("loader submission redirect (LoadingLoaderSubmissionRedirect)", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER_REDIRECT}`);
+ await page.waitForURL(/\?redirected/);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "loading",
+ location: {
+ pathname: `/${STATES.SUBMITTING_LOADER_REDIRECT}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ formMethod: "GET",
+ formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ {
+ state: "loading",
+ location: {
+ pathname: "/",
+ search: "?redirected",
+ hash: "",
+ state: {
+ _isRedirect: true,
+ },
+ key: expect.any(String),
+ },
+ formMethod: "GET",
+ formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("action submission (SubmittingAction)", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION}`);
+ await page.waitForSelector(`#${STATES.SUBMITTING_ACTION}`);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "submitting",
+ location: {
+ pathname: `/${STATES.SUBMITTING_ACTION}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ formMethod: "POST",
+ formAction: `/${STATES.SUBMITTING_ACTION}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ {
+ state: "loading",
+ location: {
+ pathname: `/${STATES.SUBMITTING_ACTION}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ formMethod: "POST",
+ formAction: `/${STATES.SUBMITTING_ACTION}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("action submission redirect (LoadingActionRedirect)", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION_REDIRECT}`);
+ await page.waitForURL(/\?redirected/);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "submitting",
+ location: {
+ pathname: `/${STATES.SUBMITTING_ACTION_REDIRECT}`,
+ search: "",
+ hash: "",
+ state: null,
+ key: expect.any(String),
+ },
+ formMethod: "POST",
+ formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ {
+ state: "loading",
+ location: {
+ pathname: "/",
+ search: "?redirected",
+ hash: "",
+ state: {
+ _isRedirect: true,
+ },
+ key: expect.any(String),
+ },
+ formMethod: "POST",
+ formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`,
+ formEncType: "application/x-www-form-urlencoded",
+ formData: expect.any(Object),
+ },
+ IDLE_STATE,
+ ]);
+ });
+
+ test("fetcher action submission redirect (LoadingFetchActionRedirect)", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await app.clickSubmitButton(`/${STATES.FETCHER_REDIRECT}`);
+ await page.waitForURL(/\?redirected/);
+ await page.waitForSelector("#loading-indicator", { state: "hidden" });
+ let navigationsCode = await app.getElement("#navigations");
+ let navigationsJson = navigationsCode.text();
+ let navigations = JSON.parse(navigationsJson);
+ expect(navigations).toEqual([
+ IDLE_STATE,
+ {
+ state: "loading",
+ location: {
+ pathname: "/",
+ search: "?redirected",
+ hash: "",
+ state: {
+ _isFetchActionRedirect: true,
+ _isRedirect: true,
+ },
+ key: expect.any(String),
+ },
+ },
+ IDLE_STATE,
+ ]);
+ });
+});
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 7430315b260..ce2705f2344 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -56,6 +56,7 @@ interface FutureConfig {
unstable_vanillaExtract: boolean | VanillaExtractOptions;
v2_errorBoundary: boolean;
v2_meta: boolean;
+ v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
}
@@ -637,6 +638,7 @@ export async function readConfig(
unstable_vanillaExtract: appConfig.future?.unstable_vanillaExtract ?? false,
v2_errorBoundary: appConfig.future?.v2_errorBoundary === true,
v2_meta: appConfig.future?.v2_meta === true,
+ v2_normalizeFormMethod: appConfig.future?.v2_normalizeFormMethod === true,
v2_routeConvention: appConfig.future?.v2_routeConvention === true,
};
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 70eeb7095d5..6286c367398 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -165,7 +165,18 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement {
};
}
- router = createBrowserRouter(routes, { hydrationData });
+ router = createBrowserRouter(routes, {
+ hydrationData,
+ future: {
+ // Pass through the Remix future flag to avoid a v1 breaking change in
+ // useNavigation() - users can control the casing via the flag in v1.
+ // useFetcher still always uppercases in the back-compat layer in v1.
+ // In v2 we can just always pass true here and remove the back-compat
+ // layer
+ v7_normalizeFormMethod:
+ window.__remixContext.future.v2_normalizeFormMethod,
+ },
+ });
}
// We need to include a wrapper RemixErrorBoundary here in case the root error
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index 3e4f9450870..7d1832a9771 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -39,6 +39,7 @@ export interface FutureConfig {
unstable_vanillaExtract: boolean | VanillaExtractOptions;
v2_errorBoundary: boolean;
v2_meta: boolean;
+ v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
}
diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts
index ce8b31cdec3..16c6670db81 100644
--- a/packages/remix-server-runtime/entry.ts
+++ b/packages/remix-server-runtime/entry.ts
@@ -31,6 +31,7 @@ export interface FutureConfig {
unstable_vanillaExtract: boolean | VanillaExtractOptions;
v2_errorBoundary: boolean;
v2_meta: boolean;
+ v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
}
diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx
index de1429be54a..3d9749e6812 100644
--- a/packages/remix-testing/create-remix-stub.tsx
+++ b/packages/remix-testing/create-remix-stub.tsx
@@ -71,6 +71,7 @@ export function createRemixStub(routes: (RouteObject | DataRouteObject)[]) {
unstable_vanillaExtract: false,
v2_errorBoundary: false,
v2_meta: false,
+ v2_normalizeFormMethod: false,
v2_routeConvention: false,
...remixConfigFuture,
},