From 561bc4f2e0257f54baefbe74e8451cd5528334dd Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 14 Mar 2023 09:22:45 -0400 Subject: [PATCH] feat(remix-react)!: remove `fetcher.type`/`fetcher.submission` (#5716) --- .changeset/remove-fetcher-back-compat.md | 8 + integration/fetcher-state-test.ts | 60 +---- packages/remix-react/components.tsx | 270 +++-------------------- packages/remix-react/transition.ts | 121 ++-------- 4 files changed, 66 insertions(+), 393 deletions(-) create mode 100644 .changeset/remove-fetcher-back-compat.md diff --git a/.changeset/remove-fetcher-back-compat.md b/.changeset/remove-fetcher-back-compat.md new file mode 100644 index 00000000000..a9880665759 --- /dev/null +++ b/.changeset/remove-fetcher-back-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 index b8a174d8718..33424bcc57b 100644 --- a/integration/fetcher-state-test.ts +++ b/integration/fetcher-state-test.ts @@ -47,16 +47,10 @@ test.describe("fetcher 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, }); } @@ -100,12 +94,11 @@ test.describe("fetcher states", () => { const fetcher = useFetcher(); return ( <> - {fetcher.type === 'init' ? + {fetcher.state === 'idle' && fetcher.data == null ?
                     {
                       JSON.stringify({
                         state: fetcher.state,
-                        type: fetcher.type,
                         formMethod: fetcher.formMethod,
                         formAction: fetcher.formAction,
                         formData: fetcher.formData,
@@ -163,7 +156,11 @@ test.describe("fetcher states", () => {
     let text = (await app.getElement("#initial-state")).text();
     expect(JSON.parse(text)).toEqual({
       state: "idle",
-      type: "init",
+      data: undefined,
+      formData: undefined,
+      formAction: undefined,
+      formMethod: undefined,
+      formEncType: undefined,
     });
   });
 
@@ -176,38 +173,23 @@ test.describe("fetcher states", () => {
     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",
         },
@@ -223,25 +205,14 @@ test.describe("fetcher states", () => {
     let text = (await app.getElement("#states")).text();
     expect(JSON.parse(text)).toEqual([
       {
-        state: "submitting",
-        type: "loaderSubmission",
+        state: "loading",
         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",
         },
@@ -258,35 +229,20 @@ test.describe("fetcher states", () => {
     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",
       },
     ]);
   });
@@ -300,12 +256,10 @@ test.describe("fetcher states", () => {
     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 b23a8758346..e3c467c4671 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -15,6 +15,7 @@ import type {
   FormProps,
   Params,
   SubmitFunction,
+  V7_FormMethod,
 } from "react-router-dom";
 import {
   Await as AwaitRR,
@@ -66,13 +67,7 @@ import type {
   V2_MetaMatch,
   V2_MetaMatches,
 } from "./routeModules";
-import type {
-  Fetcher,
-  FetcherStates,
-  LoaderSubmission,
-  ActionSubmission,
-} from "./transition";
-import { IDLE_FETCHER } from "./transition";
+import type { Fetcher, FetcherStates } from "./transition";
 import { logDeprecationOnce } from "./warnings";
 
 function useDataRouterContext() {
@@ -374,18 +369,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.
  *
@@ -1309,21 +1292,7 @@ export function useActionData(): SerializeFrom | undefined {
  */
 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;
-  });
+  return fetchers.map((f) => convertRouterFetcherToRemixFetcher(f));
 }
 
 export type FetcherWithComponents = Fetcher & {
@@ -1346,62 +1315,17 @@ export function useFetcher(): FetcherWithComponents<
   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 remixFetcher = convertRouterFetcherToRemixFetcher(fetcherRR);
     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 {
@@ -1416,66 +1340,29 @@ function convertRouterFetcherToRemixFetcher(
     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"] = {
+  if (state === "submitting") {
+    if (
+      formMethod &&
+      formAction &&
+      formEncType &&
+      (formData || json !== undefined || text !== undefined)
+    ) {
+      // @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
+      let fetcher: FetcherStates["Submitting"] = {
         state,
-        type: "actionSubmission",
-        formMethod: formMethod.toUpperCase() as ActionSubmission["method"],
+        formMethod: formMethod.toUpperCase() as V7_FormMethod,
         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"
+      // "submitting" will always have these fields
       invariant(
         false,
         "Encountered an unexpected fetcher scenario in useFetcher()"
@@ -1484,117 +1371,30 @@ function convertRouterFetcherToRemixFetcher(
   }
 
   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; - } - } + // @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 + let fetcher: FetcherStates["Loading"] = { + state, + formMethod: formMethod?.toUpperCase() as V7_FormMethod, + formAction, + formEncType, + formData, + json, + text, + data, + }; + return fetcher; } - // If all else fails, it's a normal load! - let fetcher: FetcherStates["Loading"] = { - state: "loading", - type: "normalLoad", + let fetcher: FetcherStates["Idle"] = { + state: "idle", formMethod: undefined, formAction: undefined, + formEncType: undefined, formData: undefined, json: undefined, text: undefined, - formEncType: undefined, - submission: undefined, data, }; return fetcher; @@ -1602,7 +1402,7 @@ function convertRouterFetcherToRemixFetcher( // 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 +// If running an un-bundled server outside `remix dev` you will still need // to set the REMIX_DEV_SERVER_WS_PORT manually. export const LiveReload = process.env.NODE_ENV !== "development" diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts index 7b7a7557052..e763329d38b 100644 --- a/packages/remix-react/transition.ts +++ b/packages/remix-react/transition.ts @@ -1,18 +1,4 @@ -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"; -} +import type { FormEncType, V7_FormMethod } from "react-router-dom"; // Thanks https://github.com/sindresorhus/type-fest! type JsonObject = { [Key in string]: JsonValue } & { @@ -41,110 +27,35 @@ type FetcherSubmissionDataTypes = 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"; +type EmptyFetcherSubmissionDataType = { + formData: undefined; + json: undefined; + text: undefined; }; -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: string; - submission: FetcherActionSubmission; data: TData | undefined; - } & FetcherSubmissionDataTypes; - SubmittingLoader: { + } & EmptyFetcherSubmissionDataType; + Loading: { + state: "loading"; + formMethod: V7_FormMethod | undefined; + formAction: string | undefined; + formEncType: FormEncType | undefined; + data: TData | undefined; + } & (EmptyFetcherSubmissionDataType | FetcherSubmissionDataTypes); + Submitting: { state: "submitting"; - type: "loaderSubmission"; - formMethod: FetcherLoaderSubmission["method"]; + formMethod: V7_FormMethod; formAction: string; - formEncType: string; - submission: FetcherLoaderSubmission; + formEncType: FormEncType; data: TData | undefined; } & FetcherSubmissionDataTypes; - ReloadingAction: { - state: "loading"; - type: "actionReload"; - formMethod: FetcherActionSubmission["method"]; - formAction: string; - formEncType: string; - submission: FetcherActionSubmission; - data: TData; - } & FetcherSubmissionDataTypes; - LoadingActionRedirect: { - state: "loading"; - type: "actionRedirect"; - formMethod: FetcherActionSubmission["method"]; - formAction: string; - formEncType: string; - 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, -};