Repository: remix-run/remix
Pull request #1109 (2021-12-17 14:53:08)

fix: persist `fetcher.data` through re-fetches
Previously `fetcher.data` was erroneously removed when a fetcher was reloaded or resubmitted, this required apps to duplicate the state into their own app state to build a UI that didn't flicker data.


Patch:
diff --git a/docs/api/remix.md b/docs/api/remix.md
index f9b04c663..e5f5c8f7a 100644
--- a/docs/api/remix.md
+++ b/docs/api/remix.md
@@ -661,11 +661,7 @@ It is not available when the fetcher state is "idle" or "loading".
 
 #### `fetcher.data`
 
-When using `<fetcher.Form>` or `fetcher.submit()`, the action or loader's response is stored here.
-
-In the case of action submissions, the data is available even before the routes on the page are reloaded.
-
-It is not available when the fetcher state is "submitting". If you need it around when the same form is resubmit, you'll need to persist it to your own React state.
+The returned response data from your loader or action is stored here. Once the data is set, it persists on the fetcher even through reloads and resubmissions (like calling `fetcher.load()` again after having already read the data).
 
 #### `fetcher.Form`
 
diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts
index 3a17ee2b1..48cbb388c 100644
--- a/packages/remix-react/transition.ts
+++ b/packages/remix-react/transition.ts
@@ -207,7 +207,7 @@ type FetcherStates<TData = any> = {
     state: "submitting";
     type: "loaderSubmission";
     submission: LoaderSubmission;
-    data: undefined;
+    data: TData | undefined;
   };
   ReloadingAction: {
     state: "loading";
@@ -219,7 +219,7 @@ type FetcherStates<TData = any> = {
     state: "loading";
     type: "normalLoad";
     submission: undefined;
-    data: undefined;
+    data: TData | undefined;
   };
   Done: {
     state: "idle";
@@ -490,11 +490,13 @@ export function createTransitionManager(init: TransitionManagerInit) {
     submission: ActionSubmission,
     match: ClientMatch
   ) {
+    let currentFetcher = state.fetchers.get(key);
+
     let fetcher: FetcherStates["SubmittingAction"] = {
       state: "submitting",
       type: "actionSubmission",
       submission,
-      data: undefined
+      data: currentFetcher?.data || undefined
     };
     state.fetchers.set(key, fetcher);
 
@@ -675,12 +677,14 @@ export function createTransitionManager(init: TransitionManagerInit) {
     submission: LoaderSubmission,
     match: ClientMatch
   ) {
+    let currentFetcher = state.fetchers.get(key);
     let fetcher: FetcherStates["SubmittingLoader"] = {
       state: "submitting",
       type: "loaderSubmission",
       submission,
-      data: undefined
+      data: currentFetcher?.data || undefined
     };
+
     state.fetchers.set(key, fetcher);
     update({ fetchers: new Map(state.fetchers) });
 
@@ -726,11 +730,13 @@ export function createTransitionManager(init: TransitionManagerInit) {
     key: string,
     match: ClientMatch
   ) {
+    let currentFetcher = state.fetchers.get(key);
+
     let fetcher: FetcherStates["Loading"] = {
       state: "loading",
       type: "normalLoad",
       submission: undefined,
-      data: undefined
+      data: currentFetcher?.data || undefined
     };
 
     state.fetchers.set(key, fetcher);


Test patch:
diff --git a/packages/remix-react/__tests__/transition-test.tsx b/packages/remix-react/__tests__/transition-test.tsx
index e977ba388..64774f9d8 100644
--- a/packages/remix-react/__tests__/transition-test.tsx
+++ b/packages/remix-react/__tests__/transition-test.tsx
@@ -1084,6 +1084,30 @@ describe("fetcher states", () => {
     expect(fetcher.data).toBe("A DATA");
   });
 
+  test("loader re-fetch", async () => {
+    let t = setup({ url: "/foo" });
+    let key = "key";
+
+    let A = t.fetch.get("/foo", key);
+    await A.loader.resolve("A DATA");
+    let fetcher = t.getFetcher(key);
+    expect(fetcher.state).toBe("idle");
+    expect(fetcher.type).toBe("done");
+    expect(fetcher.data).toBe("A DATA");
+
+    let B = t.fetch.get("/foo", key);
+    fetcher = t.getFetcher(key);
+    expect(fetcher.state).toBe("loading");
+    expect(fetcher.type).toBe("normalLoad");
+    expect(fetcher.data).toBe("A DATA");
+
+    await B.loader.resolve("B DATA");
+    fetcher = t.getFetcher(key);
+    expect(fetcher.state).toBe("idle");
+    expect(fetcher.type).toBe("done");
+    expect(fetcher.data).toBe("B DATA");
+  });
+
   test("loader submission fetch", async () => {
     let t = setup({ url: "/foo" });
 
@@ -1099,6 +1123,19 @@ describe("fetcher states", () => {
     expect(fetcher.data).toBe("A DATA");
   });
 
+  test("loader submission re-fetch", async () => {
+    let t = setup({ url: "/foo" });
+    let key = "key";
+
+    let A = t.fetch.submitGet("/foo", key);
+    await A.loader.resolve("A DATA");
+    t.fetch.submitGet("/foo", key);
+    let fetcher = t.getFetcher(key);
+    expect(fetcher.state).toBe("submitting");
+    expect(fetcher.type).toBe("loaderSubmission");
+    expect(fetcher.data).toBe("A DATA");
+  });
+
   test("action fetch", async () => {
     let t = setup({ url: "/foo" });
 
@@ -1125,6 +1162,19 @@ describe("fetcher states", () => {
       }
     `);
   });
+
+  test("action re-fetch", async () => {
+    let t = setup({ url: "/foo" });
+    let key = "key";
+
+    let A = t.fetch.post("/foo", key);
+    await A.action.resolve("A ACTION");
+    await A.loader.resolve("A DATA");
+    t.fetch.post("/foo", key);
+    let fetcher = t.getFetcher(key);
+    expect(fetcher.state).toBe("submitting");
+    expect(fetcher.data).toBe("A ACTION");
+  });
 });
 
 describe("fetchers", () => {
@@ -1325,7 +1375,7 @@ describe("fetcher resubmissions/re-gets", () => {
 
       let B = t.fetch.post("/foo", key);
       expect(A.loader.abortMock.calls.length).toBe(1);
-      expect(t.getFetcher(key).data).toBeUndefined();
+      expect(t.getFetcher(key).data).toBe("A ACTION");
 
       await A.loader.resolve("A LOADER");
       expect(t.getState().loaderData.foo).toBeUndefined();
@@ -1334,7 +1384,7 @@ describe("fetcher resubmissions/re-gets", () => {
       expect(B.action.abortMock.calls.length).toBe(1);
 
       await B.action.resolve("B ACTION");
-      expect(t.getFetcher(key).data).toBeUndefined();
+      expect(t.getFetcher(key).data).toBe("A ACTION");
 
       await C.action.resolve("C ACTION");
       expect(t.getFetcher(key).data).toBe("C ACTION");