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 ( + + ); + } + `, + [`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, },