diff --git a/.changeset/v2-remove-fetcher-compat.md b/.changeset/v2-remove-fetcher-compat.md new file mode 100644 index 00000000000..a9880665759 --- /dev/null +++ b/.changeset/v2-remove-fetcher-compat.md @@ -0,0 +1,8 @@ +--- +"@remix-run/react": major +--- + +Remove back-compat layer for `useFetcher`/`useFetchers`. This includes a few small breaking changes: +* `fetcher.type` has been removed since it can be derived from other available information +* "Submission" fields have been flattened from `fetcher.submission` down onto the root `fetcher` object, and prefixed with `form` in some cases (`fetcher.submission.action` => `fetcher.formAction`) +* `` is now more accurately categorized as `state:"loading"` instead of `state:"submitting"` to better align with the underlying GET request diff --git a/integration/fetcher-state-test.ts b/integration/fetcher-state-test.ts deleted file mode 100644 index b8a174d8718..00000000000 --- a/integration/fetcher-state-test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { ServerMode } from "@remix-run/server-runtime/mode"; - -import { createAppFixture, createFixture, js } from "./helpers/create-fixture"; -import type { Fixture, AppFixture } from "./helpers/create-fixture"; -import { PlaywrightFixture } from "./helpers/playwright-fixture"; - -// idle, done, actionReload are tested during the testing of these flows -const TYPES = { - actionSubmission: "actionSubmission", - loaderSubmission: "loaderSubmission", - actionRedirect: "actionRedirect", - normalLoad: "normalLoad", -}; - -test.describe("fetcher states", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - config: { - future: { v2_routeConvention: true }, - }, - files: { - "app/root.jsx": js` - import { useMemo, useRef } from "react"; - import { Outlet, Scripts, useFetchers } from "@remix-run/react"; - - export default function Comp() { - // Only gonna use a single fetcher in any given test but this way - // we can route away from the child route and preserve the info - const [fetcher] = useFetchers(); - const fetcherRef = useRef(); - const states = useMemo(() => { - if (!fetcher) return - const savedStates = fetcherRef.current || []; - // Concurrent mode can cause multiple re-renders here on transitions - // here so only re-capture when our tested fetcher changes states - if (savedStates[savedStates.length - 1]?.state !== fetcher.state) { - savedStates.push({ - state: fetcher.state, - type: fetcher.type, - formMethod: fetcher.formMethod, - formAction: fetcher.formAction, - formData:fetcher.formData ? Object.fromEntries(fetcher.formData.entries()) : undefined, - formEncType: fetcher.formEncType, - submission: fetcher.submission ? { - ...fetcher.submission, - formData: Object.fromEntries(fetcher.submission.formData.entries()), - key: undefined - }: undefined, - data: fetcher.data, - }); - } - fetcherRef.current = savedStates; - return savedStates; - }, [fetcher]); - - return ( - - Test - - - {fetcher && fetcher.state != "idle" && ( -

Loading...

- )} -

- - {JSON.stringify(states, null, 2)} - -

- - - - ); - } - `, - "app/routes/page.jsx": js` - import { redirect } from "@remix-run/node"; - import { useFetcher } from "@remix-run/react"; - export function loader() { - return { from: 'loader' } - } - export async function action({ request }) { - let fd = await request.formData() - if (fd.has('redirect')) { - return redirect('/redirect'); - } - return { from: 'action' } - } - export default function() { - const fetcher = useFetcher(); - return ( - <> - {fetcher.type === 'init' ? -
-                    {
-                      JSON.stringify({
-                        state: fetcher.state,
-                        type: fetcher.type,
-                        formMethod: fetcher.formMethod,
-                        formAction: fetcher.formAction,
-                        formData: fetcher.formData,
-                        formEncType: fetcher.formEncType,
-                        submission: fetcher.submission,
-                        data: fetcher.data,
-                      })
-                    }
-                  
: - null} - - ${TYPES.actionSubmission} - - - - ${TYPES.loaderSubmission} - - - - ${TYPES.actionRedirect} - - - - - ); - } - `, - "app/routes/redirect.jsx": js` - export function loader() { - return { from: 'redirect loader' } - } - export default function() { - return

Redirect

; - } - `, - }, - }); - - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => appFixture.close()); - - test("represents a initial fetcher", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/page", true); - let text = (await app.getElement("#initial-state")).text(); - expect(JSON.parse(text)).toEqual({ - state: "idle", - type: "init", - }); - }); - - test("represents an actionSubmission fetcher", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/page", true); - await app.clickElement(`#${TYPES.actionSubmission}`); - await page.waitForSelector("#loading", { state: "hidden" }); - let text = (await app.getElement("#states")).text(); - expect(JSON.parse(text)).toEqual([ - { - state: "submitting", - type: "actionSubmission", - formData: { key: "value" }, - formAction: "/page", - formMethod: "POST", - formEncType: "application/x-www-form-urlencoded", - submission: { - formData: { key: "value" }, - action: "/page", - method: "POST", - encType: "application/x-www-form-urlencoded", - }, - }, - { - state: "loading", - type: "actionReload", - formData: { key: "value" }, - formAction: "/page", - formMethod: "POST", - formEncType: "application/x-www-form-urlencoded", - submission: { - formData: { key: "value" }, - action: "/page", - method: "POST", - encType: "application/x-www-form-urlencoded", - }, - data: { - from: "action", - }, - }, - { - state: "idle", - type: "done", - data: { - from: "action", - }, - }, - ]); - }); - - test("represents a loaderSubmission fetcher", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/page", true); - await app.clickElement(`#${TYPES.loaderSubmission}`); - await page.waitForSelector("#loading", { state: "hidden" }); - let text = (await app.getElement("#states")).text(); - expect(JSON.parse(text)).toEqual([ - { - state: "submitting", - type: "loaderSubmission", - formData: { key: "value" }, - formAction: "/page", - formMethod: "GET", - formEncType: "application/x-www-form-urlencoded", - submission: { - formData: { key: "value" }, - // Note: This is a bug in Remix but we're going to keep it that way - // in useTransition (including the back-compat version) and it'll be - // fixed with useNavigation - action: "/page?key=value", - method: "GET", - encType: "application/x-www-form-urlencoded", - }, - }, - { - state: "idle", - type: "done", - data: { - from: "loader", - }, - }, - ]); - }); - - test("represents an actionRedirect fetcher", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/page", true); - await app.clickElement(`#${TYPES.actionRedirect}`); - await page.waitForSelector("#loading", { state: "hidden" }); - let text = (await app.getElement("#states")).text(); - expect(JSON.parse(text)).toEqual([ - { - state: "submitting", - type: "actionSubmission", - formData: { redirect: "yes" }, - formAction: "/page", - formMethod: "POST", - formEncType: "application/x-www-form-urlencoded", - submission: { - formData: { redirect: "yes" }, - action: "/page", - method: "POST", - encType: "application/x-www-form-urlencoded", - }, - }, - { - state: "loading", - type: "actionRedirect", - formData: { redirect: "yes" }, - formAction: "/page", - formMethod: "POST", - formEncType: "application/x-www-form-urlencoded", - submission: { - formData: { redirect: "yes" }, - action: "/page", - method: "POST", - encType: "application/x-www-form-urlencoded", - }, - }, - { - state: "idle", - type: "done", - }, - ]); - }); - - test("represents a normalLoad fetcher", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/page", true); - await app.clickElement(`#${TYPES.normalLoad}`); - await page.waitForSelector("#loading", { state: "hidden" }); - let text = (await app.getElement("#states")).text(); - expect(JSON.parse(text)).toEqual([ - { - state: "loading", - type: "normalLoad", - }, - { - data: { from: "loader" }, - state: "idle", - type: "done", - }, - ]); - }); -}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 8c08e56efd8..c5c4259ebb5 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -9,13 +9,7 @@ import type { UNSAFE_DeferredData as DeferredData, TrackedPromise, } from "@remix-run/router"; -import type { - LinkProps, - NavLinkProps, - FormProps, - Params, - SubmitFunction, -} from "react-router-dom"; +import type { LinkProps, NavLinkProps, Params } from "react-router-dom"; import { Await as AwaitRR, Link as RouterLink, @@ -26,8 +20,6 @@ import { isRouteErrorResponse, matchRoutes, useAsyncError, - useFetcher as useFetcherRR, - useFetchers as useFetchersRR, useActionData as useActionDataRR, useLoaderData as useLoaderDataRR, useRouteLoaderData as useRouteLoaderDataRR, @@ -66,13 +58,6 @@ import type { V2_MetaMatch, V2_MetaMatches, } from "./routeModules"; -import type { - Fetcher, - FetcherStates, - LoaderSubmission, - ActionSubmission, -} from "./transition"; -import { IDLE_FETCHER } from "./transition"; import { logDeprecationOnce } from "./warnings"; function useDataRouterContext() { @@ -374,18 +359,6 @@ let linksWarning = "For instructions on making this change see " + "https://remix.run/docs/en/v1.15.0/pages/v2#links-imagesizes-and-imagesrcset"; -let fetcherTypeWarning = - "⚠️ REMIX FUTURE CHANGE: `fetcher.type` will be removed in v2. " + - "Please use `fetcher.state`, `fetcher.formData`, and `fetcher.data` to achieve the same UX. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#usefetcher"; - -let fetcherSubmissionWarning = - "⚠️ REMIX FUTURE CHANGE : `fetcher.submission` will be removed in v2. " + - "The submission fields are now part of the fetcher object itself (`fetcher.formData`). " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#usefetcher"; - /** * Renders the `` tags for the current routes. * @@ -1338,305 +1311,6 @@ export function useActionData(): SerializeFrom | undefined { return useActionDataRR() as SerializeFrom | undefined; } -/** - * Provides all fetchers currently on the page. Useful for layouts and parent - * routes that need to provide pending/optimistic UI regarding the fetch. - * - * @see https://remix.run/api/remix#usefetchers - */ -export function useFetchers(): Fetcher[] { - let fetchers = useFetchersRR(); - return fetchers.map((f) => { - let fetcher = convertRouterFetcherToRemixFetcher({ - state: f.state, - data: f.data, - formMethod: f.formMethod, - formAction: f.formAction, - formEncType: f.formEncType, - formData: f.formData, - json: f.json, - text: f.text, - " _hasFetcherDoneAnything ": f[" _hasFetcherDoneAnything "], - }); - addFetcherDeprecationWarnings(fetcher); - return fetcher; - }); -} - -export type FetcherWithComponents = Fetcher & { - Form: React.ForwardRefExoticComponent< - FormProps & React.RefAttributes - >; - submit: SubmitFunction; - load: (href: string) => void; -}; - -/** - * Interacts with route loaders and actions without causing a navigation. Great - * for any interaction that stays on the same page. - * - * @see https://remix.run/hooks/use-fetcher - */ -export function useFetcher(): FetcherWithComponents< - SerializeFrom -> { - let fetcherRR = useFetcherRR(); - - return React.useMemo(() => { - let remixFetcher = convertRouterFetcherToRemixFetcher({ - state: fetcherRR.state, - data: fetcherRR.data, - formMethod: fetcherRR.formMethod, - formAction: fetcherRR.formAction, - formEncType: fetcherRR.formEncType, - formData: fetcherRR.formData, - json: fetcherRR.json, - text: fetcherRR.text, - " _hasFetcherDoneAnything ": fetcherRR[" _hasFetcherDoneAnything "], - }); - let fetcherWithComponents = { - ...remixFetcher, - load: fetcherRR.load, - submit: fetcherRR.submit, - Form: fetcherRR.Form, - }; - addFetcherDeprecationWarnings(fetcherWithComponents); - return fetcherWithComponents; - }, [fetcherRR]); -} - -function addFetcherDeprecationWarnings(fetcher: Fetcher) { - let type: Fetcher["type"] = fetcher.type; - Object.defineProperty(fetcher, "type", { - get() { - logDeprecationOnce(fetcherTypeWarning); - return type; - }, - set(value: Fetcher["type"]) { - // Devs should *not* be doing this but we don't want to break their - // current app if they are - type = value; - }, - // These settings should make this behave like a normal object `type` field - configurable: true, - enumerable: true, - }); - - let submission: Fetcher["submission"] = fetcher.submission; - Object.defineProperty(fetcher, "submission", { - get() { - logDeprecationOnce(fetcherSubmissionWarning); - return submission; - }, - set(value: Fetcher["submission"]) { - // Devs should *not* be doing this but we don't want to break their - // current app if they are - submission = value; - }, - // These settings should make this behave like a normal object `type` field - configurable: true, - enumerable: true, - }); -} - -function convertRouterFetcherToRemixFetcher( - fetcherRR: Omit, "load" | "submit" | "Form"> -): Fetcher { - let { - state, - formMethod, - formAction, - formEncType, - formData, - json, - text, - data, - } = fetcherRR; - - let isActionSubmission = - formMethod != null && - ["POST", "PUT", "PATCH", "DELETE"].includes(formMethod.toUpperCase()); - - if (state === "idle") { - if (fetcherRR[" _hasFetcherDoneAnything "] === true) { - let fetcher: FetcherStates["Done"] = { - state: "idle", - type: "done", - formMethod: undefined, - formAction: undefined, - formEncType: undefined, - formData: undefined, - json: undefined, - text: undefined, - submission: undefined, - data, - }; - return fetcher; - } else { - let fetcher: FetcherStates["Idle"] = IDLE_FETCHER; - return fetcher; - } - } - - if ( - state === "submitting" && - formMethod && - formAction && - formEncType && - (formData || json !== undefined || text !== undefined) - ) { - if (isActionSubmission) { - // Actively submitting to an action - let fetcher: FetcherStates["SubmittingAction"] = { - state, - type: "actionSubmission", - formMethod: formMethod.toUpperCase() as ActionSubmission["method"], - formAction, - formEncType, - formData, - json, - text, - // @ts-expect-error formData/json/text are mutually exclusive in the type, - // so TS can't be sure these meet that criteria, but as a straight - // assignment from the RR fetcher we know they will - submission: { - method: formMethod.toUpperCase() as ActionSubmission["method"], - action: formAction, - encType: formEncType, - formData, - json, - text, - key: "", - }, - data, - }; - return fetcher; - } else { - // @remix-run/router doesn't mark loader submissions as state: "submitting" - invariant( - false, - "Encountered an unexpected fetcher scenario in useFetcher()" - ); - } - } - - if (state === "loading") { - if (formMethod && formAction && formEncType) { - if (isActionSubmission) { - if (data) { - // In a loading state but we have data - must be an actionReload - let fetcher: FetcherStates["ReloadingAction"] = { - state, - type: "actionReload", - formMethod: formMethod.toUpperCase() as ActionSubmission["method"], - formAction, - formEncType, - formData, - json, - text, - // @ts-expect-error formData/json/text are mutually exclusive in the type, - // so TS can't be sure these meet that criteria, but as a straight - // assignment from the RR fetcher we know they will - submission: { - method: formMethod.toUpperCase() as ActionSubmission["method"], - action: formAction, - encType: formEncType, - formData, - json, - text, - key: "", - }, - data, - }; - return fetcher; - } else { - let fetcher: FetcherStates["LoadingActionRedirect"] = { - state, - type: "actionRedirect", - formMethod: formMethod.toUpperCase() as ActionSubmission["method"], - formAction, - formEncType, - formData, - json, - text, - // @ts-expect-error formData/json/text are mutually exclusive in the type, - // so TS can't be sure these meet that criteria, but as a straight - // assignment from the RR fetcher we know they will - submission: { - method: formMethod.toUpperCase() as ActionSubmission["method"], - action: formAction, - encType: formEncType, - formData, - json, - text, - key: "", - }, - data: undefined, - }; - return fetcher; - } - } else { - // The new router fixes a bug in useTransition where the submission - // "action" represents the request URL not the state of the
in - // the DOM. Back-port it here to maintain behavior, but useNavigation - // will fix this bug. - let url = new URL(formAction, window.location.origin); - - if (formData) { - // This typing override should be safe since this is only running for - // GET submissions and over in @remix-run/router we have an invariant - // if you have any non-string values in your FormData when we attempt - // to convert them to URLSearchParams - url.search = new URLSearchParams( - formData.entries() as unknown as [string, string][] - ).toString(); - } - - // Actively "submitting" to a loader - let fetcher: FetcherStates["SubmittingLoader"] = { - state: "submitting", - type: "loaderSubmission", - formMethod: formMethod.toUpperCase() as LoaderSubmission["method"], - formAction, - formEncType, - formData, - json, - text, - // @ts-expect-error formData/json/text are mutually exclusive in the type, - // so TS can't be sure these meet that criteria, but as a straight - // assignment from the RR fetcher we know they will - submission: { - method: formMethod.toUpperCase() as LoaderSubmission["method"], - action: url.pathname + url.search, - encType: formEncType, - formData, - json, - text, - key: "", - }, - data, - }; - return fetcher; - } - } - } - - // If all else fails, it's a normal load! - let fetcher: FetcherStates["Loading"] = { - state: "loading", - type: "normalLoad", - formMethod: undefined, - formAction: undefined, - formData: undefined, - json: undefined, - text: undefined, - formEncType: undefined, - submission: undefined, - data, - }; - return fetcher; -} - // Dead Code Elimination magic for production builds. // This way devs don't have to worry about doing the NODE_ENV check themselves. // If running an un-bundled server outside of `remix dev` you will still need diff --git a/packages/remix-react/index.tsx b/packages/remix-react/index.tsx index 783b8761a47..ec7c5cce007 100644 --- a/packages/remix-react/index.tsx +++ b/packages/remix-react/index.tsx @@ -1,6 +1,8 @@ export type { RemixBrowserProps } from "./browser"; export { RemixBrowser } from "./browser"; export type { + Fetcher, + FetcherWithComponents, FormEncType, FormMethod, FormProps, @@ -21,6 +23,8 @@ export { useAsyncValue, isRouteErrorResponse, useBeforeUnload, + useFetcher, + useFetchers, useFormAction, useHref, useLocation, @@ -42,7 +46,6 @@ export { export type { AwaitProps, - FetcherWithComponents, RouteMatch, RemixNavLinkProps as NavLinkProps, RemixLinkProps as LinkProps, @@ -56,8 +59,6 @@ export { NavLink, PrefetchPageLinks, LiveReload, - useFetcher, - useFetchers, useLoaderData, useRouteLoaderData, useMatches, @@ -84,8 +85,6 @@ export { ScrollRestoration } from "./scroll-restoration"; export type { RemixServerProps } from "./server"; export { RemixServer } from "./server"; -export type { Fetcher } from "./transition"; - export type { FutureConfig as UNSAFE_FutureConfig, AssetsManifest as UNSAFE_AssetsManifest, diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts deleted file mode 100644 index 82d78834375..00000000000 --- a/packages/remix-react/transition.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Location, FormEncType } from "react-router-dom"; - -export interface Submission { - action: string; - method: string; - formData: FormData; - encType: string; - key: string; -} - -export interface ActionSubmission extends Submission { - method: "POST" | "PUT" | "PATCH" | "DELETE"; -} - -export interface LoaderSubmission extends Submission { - method: "GET"; -} - -// 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; - -// Fetchers need a separate set of types to reflect the json/text submission -// support in react-router. We do not carry that into useTransition since -// it's deprecated -type FetcherSubmissionDataTypes = - | { - formData: FormData; - json: undefined; - text: undefined; - } - | { - formData: undefined; - json: JsonValue; - text: undefined; - } - | { - formData: undefined; - json: undefined; - text: string; - }; - -export type FetcherSubmission = { - action: string; - method: string; - encType: string; - key: string; -} & FetcherSubmissionDataTypes; - -export type FetcherActionSubmission = FetcherSubmission & { - method: "POST" | "PUT" | "PATCH" | "DELETE"; -}; - -export type FetcherLoaderSubmission = FetcherSubmission & { - method: "GET"; -}; - -// TODO: keep data around on resubmission? -export type FetcherStates = { - Idle: { - state: "idle"; - type: "init"; - formMethod: undefined; - formAction: undefined; - formEncType: undefined; - formData: undefined; - json: undefined; - text: undefined; - submission: undefined; - data: undefined; - }; - SubmittingAction: { - state: "submitting"; - type: "actionSubmission"; - formMethod: FetcherActionSubmission["method"]; - formAction: string; - formEncType: FormEncType; - submission: FetcherActionSubmission; - data: TData | undefined; - } & FetcherSubmissionDataTypes; - SubmittingLoader: { - state: "submitting"; - type: "loaderSubmission"; - formMethod: FetcherLoaderSubmission["method"]; - formAction: string; - formEncType: FormEncType; - submission: FetcherLoaderSubmission; - data: TData | undefined; - } & FetcherSubmissionDataTypes; - ReloadingAction: { - state: "loading"; - type: "actionReload"; - formMethod: FetcherActionSubmission["method"]; - formAction: string; - formEncType: FormEncType; - submission: FetcherActionSubmission; - data: TData; - } & FetcherSubmissionDataTypes; - LoadingActionRedirect: { - state: "loading"; - type: "actionRedirect"; - formMethod: FetcherActionSubmission["method"]; - formAction: string; - formEncType: FormEncType; - submission: FetcherActionSubmission; - data: undefined; - } & FetcherSubmissionDataTypes; - Loading: { - state: "loading"; - type: "normalLoad"; - formMethod: undefined; - formAction: undefined; - formData: undefined; - formEncType: undefined; - json: undefined; - text: undefined; - submission: undefined; - data: TData | undefined; - }; - Done: { - state: "idle"; - type: "done"; - formMethod: undefined; - formAction: undefined; - formEncType: undefined; - formData: undefined; - json: undefined; - text: undefined; - submission: undefined; - data: TData; - }; -}; - -export type Fetcher = - FetcherStates[keyof FetcherStates]; - -export const IDLE_FETCHER: FetcherStates["Idle"] = { - state: "idle", - type: "init", - data: undefined, - formMethod: undefined, - formAction: undefined, - formEncType: undefined, - formData: undefined, - json: undefined, - text: undefined, - submission: undefined, -};