Repository: apollographql/apollo-client

Problem statement:
v3.8.3 useLazyQuery fires 2 times -unsure why
### Issue Description

We are getting 2 calls in Apollo Client when using useLazyQuery. It is exactly the same call, and passes the Authentication: Bearer header. I confirmed we are only calling the 1st parameter 1 time. And even put in a console.log('FIRE'); and only get 1 of these calls. No idea why. Any thing we can see internally to Apollo Client to debug the Apollo Client?

Using latest React 18 and NextJS.

These calls are pretty large responses. And calling it 2 times is really slowing down the site.

Thanks.

### Link to Reproduction

None

### Reproduction Steps

See above. I think they're maybe a better way to find out why it is being called 2 times.



Hints:
Hey @billnbell 👋 

Would you be able to provide a minimal reproduction of the issue? Without code samples or a reproduction to play with, its very difficult to determine what might be happening here. Any more information you can provide would be super helpful. Thanks!
OK - I will send more. But any debug mode I can see internal into this call ? useLazyQuery
You could provide us a [https://www.replay.io/](Replay) of the issue occuring, but please keep in mind that that replay will contain your authentication headers, so don't record it in a production environment.

Otherwise, maybe you can also share a code snippet with us that shows us how you are using it?
I'm having this problem with an extra request after removing one of the variables properties. 
<img width="600" alt="image" src="https://github.com/apollographql/apollo-client/assets/38090158/8e1d0340-b6f4-4148-bf40-823976a21ca6">

if the fetchPolicy property is set to "no-cache"

1) Use getDataWithVariable
2) Use function getDataWithoutVariable after function getDataWithVariable
3) Look at tab network and see it:
![image](https://github.com/apollographql/apollo-client/assets/38090158/8ec36c97-ea98-4971-a3dc-372367a0cccc)

getData makes three requests instead of two. After removing one of the properties useLazyQuery made a query with the previous value of this field.

https://github.com/apollographql/apollo-client/blob/53b7370b76465f92e439e02e3431d1c13dc58d7a/src/react/hooks/useLazyQuery.ts#L98

I think, we can remove "variables" from the default state of options, since we overwrite them by passing them to the function, is that right?
@billnbell @KataevAS if you downgrade to the latest version in the 3.7.x series, are you still able to reproduce this?
> @billnbell @KataevAS if you downgrade to the latest version in the 3.7.x series, are you still able to reproduce this?

@alessbell  3.7.15 there is a bug too
@KataevAS could you try 3.7.10 as well? There was a few fixes to `useLazyQuery` in 3.7.11 and I'm curious if any of those changes introduced this regression or if this has existed for longer. Thanks!
> @KataevAS could you try 3.7.10 as well? There was a few fixes to `useLazyQuery` in 3.7.11 and I'm curious if any of those changes introduced this regression or if this has existed for longer. Thanks!

@jerelmiller 3.7.10 same thing here
<img width="1129" alt="image" src="https://github.com/apollographql/apollo-client/assets/38090158/c0ceddd8-032f-455f-a943-e85d12a7638b">


I did this for a test and it solved the problem, since by passing variables to the useLazyQuery function I expect them to be completely replaced. I think, they are replaced after calling forceUpdate now.
Is this a fix?
@billnbell this is a hot fix that solved the problem for me.
@alessbell what do you think about this?
This is a very interesting angle (thanks for digging so deep!), but it's still hard for us to reproduce & verify this. 
Could you maybe create a small reproduction (maybe starting with [this CodeSandbox](https://codesandbox.io/s/github/apollographql/react-apollo-error-template))?
@phryneas yes I can. Look at the console please https://codesandbox.io/s/gracious-water-w6259q?file=/src/index.jsx
If I use a cache in a project, I won’t see an extra request. But with the cache disabled, an extra promise will be launched.
@KataevAS that's certainly important information - thank you!
@phryneas can I submit my edits for review?
@KataevAS A PR would be welcome, but please be aware that we haven't looked into this deep enough yet and might in the end prefer another solution, so I can't 100% guarantee that we'd go with the PR at this point.

I can trigger you a package build from that PR, though, so you can continue running an Apollo Client version without local patches :)
This solution does not work if we need to store in each request the variables passed during hook initialization
what can we do to get this fixed?
@phryneas  I think this might be interesting for you. Look at the console. https://codesandbox.io/s/elastic-khorana-lgvyf5?file=/src/index.jsx It is important that the variables match
what is the issue? 
@KataevAS in your case, one of the two queries (both to the same field with the same argument) is missing an `id`, so the normalized cache cannot correctly save that data, the second query overrides data from the first query and the first query has to run again.
If you add an `id` field (as you should always, to every entity!), your double request goes away.
@phryneas сould this be related? https://codesandbox.io/s/silly-mayer-6pr822?file=/src/index.jsx
> what can we do to get this fixed?

A solution might be to pass null values ​​of all used variables to useLazyQuery.
https://codesandbox.io/s/pensive-stitch-gp3gcg

Patch:
diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts
--- a/src/react/hooks/useLazyQuery.ts
+++ b/src/react/hooks/useLazyQuery.ts
@@ -78,7 +78,7 @@ export function useLazyQuery<
 
   // Use refs to track options and the used query to ensure the `execute`
   // function remains referentially stable between renders.
-  optionsRef.current = merged;
+  optionsRef.current = options;
   queryRef.current = document;
 
   const internalState = useInternalState<TData, TVariables>(


Test patch:
diff --git a/src/react/hooks/__tests__/useLazyQuery.test.tsx b/src/react/hooks/__tests__/useLazyQuery.test.tsx
--- a/src/react/hooks/__tests__/useLazyQuery.test.tsx
+++ b/src/react/hooks/__tests__/useLazyQuery.test.tsx
@@ -5,6 +5,7 @@ import { act, render, renderHook, waitFor } from "@testing-library/react";
 
 import {
   ApolloClient,
+  ApolloError,
   ApolloLink,
   ErrorPolicy,
   InMemoryCache,
@@ -19,6 +20,7 @@ import {
   wait,
   tick,
   MockSubscriptionLink,
+  MockLink,
 } from "../../../testing";
 import { useLazyQuery } from "../useLazyQuery";
 import { QueryResult } from "../../types/types";
@@ -1483,6 +1485,274 @@ describe("useLazyQuery Hook", () => {
     expect(fetchCount).toBe(1);
   });
 
+  // https://github.com/apollographql/apollo-client/issues/9448
+  it.each(["network-only", "no-cache", "cache-and-network"] as const)(
+    "does not issue multiple network calls when calling execute again without variables with a %s fetch policy",
+    async (fetchPolicy) => {
+      interface Data {
+        user: { id: string; name: string };
+      }
+
+      interface Variables {
+        id?: string;
+      }
+
+      const query: TypedDocumentNode<Data, Variables> = gql`
+        query UserQuery($id: ID) {
+          user(id: $id) {
+            id
+            name
+          }
+        }
+      `;
+
+      let fetchCount = 0;
+
+      const link = new ApolloLink((operation) => {
+        fetchCount++;
+        return new Observable((observer) => {
+          const { id } = operation.variables;
+
+          setTimeout(() => {
+            observer.next({
+              data: {
+                user:
+                  id ?
+                    { id, name: "John Doe" }
+                  : { id: null, name: "John Default" },
+              },
+            });
+            observer.complete();
+          }, 20);
+        });
+      });
+
+      const client = new ApolloClient({
+        link,
+        cache: new InMemoryCache(),
+      });
+
+      const { result } = renderHook(
+        () => useLazyQuery(query, { fetchPolicy }),
+        {
+          wrapper: ({ children }) => (
+            <ApolloProvider client={client}>{children}</ApolloProvider>
+          ),
+        }
+      );
+
+      await act(() => result.current[0]({ variables: { id: "2" } }));
+
+      expect(fetchCount).toBe(1);
+
+      await waitFor(() => {
+        expect(result.current[1].data).toEqual({
+          user: { id: "2", name: "John Doe" },
+        });
+      });
+
+      expect(fetchCount).toBe(1);
+
+      await act(() => result.current[0]());
+
+      await waitFor(() => {
+        expect(result.current[1].data).toEqual({
+          user: { id: null, name: "John Default" },
+        });
+      });
+
+      expect(fetchCount).toBe(2);
+    }
+  );
+
+  it("maintains stable execute function when passing in dynamic function options", async () => {
+    interface Data {
+      user: { id: string; name: string };
+    }
+
+    interface Variables {
+      id: string;
+    }
+
+    const query: TypedDocumentNode<Data, Variables> = gql`
+      query UserQuery($id: ID!) {
+        user(id: $id) {
+          id
+          name
+        }
+      }
+    `;
+
+    const link = new MockLink([
+      {
+        request: { query, variables: { id: "1" } },
+        result: { data: { user: { id: "1", name: "John Doe" } } },
+        delay: 20,
+      },
+      {
+        request: { query, variables: { id: "2" } },
+        result: { errors: [new GraphQLError("Oops")] },
+        delay: 20,
+      },
+      {
+        request: { query, variables: { id: "3" } },
+        result: { data: { user: { id: "3", name: "Johnny Three" } } },
+        delay: 20,
+        maxUsageCount: Number.POSITIVE_INFINITY,
+      },
+    ]);
+
+    const client = new ApolloClient({ link, cache: new InMemoryCache() });
+
+    let countRef = { current: 0 };
+
+    const trackClosureValue = jest.fn();
+
+    const { result, rerender } = renderHook(
+      () => {
+        let count = countRef.current;
+
+        return useLazyQuery(query, {
+          fetchPolicy: "cache-first",
+          variables: { id: "1" },
+          onCompleted: () => {
+            trackClosureValue("onCompleted", count);
+          },
+          onError: () => {
+            trackClosureValue("onError", count);
+          },
+          skipPollAttempt: () => {
+            trackClosureValue("skipPollAttempt", count);
+            return false;
+          },
+          nextFetchPolicy: (currentFetchPolicy) => {
+            trackClosureValue("nextFetchPolicy", count);
+            return currentFetchPolicy;
+          },
+        });
+      },
+      {
+        wrapper: ({ children }) => (
+          <ApolloProvider client={client}>{children}</ApolloProvider>
+        ),
+      }
+    );
+
+    const [originalExecute] = result.current;
+
+    countRef.current++;
+    rerender();
+
+    expect(result.current[0]).toBe(originalExecute);
+
+    // Check for stale closures with onCompleted
+    await act(() => result.current[0]());
+    await waitFor(() => {
+      expect(result.current[1].data).toEqual({
+        user: { id: "1", name: "John Doe" },
+      });
+    });
+
+    // after fetch
+    expect(trackClosureValue).toHaveBeenNthCalledWith(1, "nextFetchPolicy", 1);
+    expect(trackClosureValue).toHaveBeenNthCalledWith(2, "onCompleted", 1);
+    trackClosureValue.mockClear();
+
+    countRef.current++;
+    rerender();
+
+    expect(result.current[0]).toBe(originalExecute);
+
+    // Check for stale closures with onError
+    await act(() => result.current[0]({ variables: { id: "2" } }));
+    await waitFor(() => {
+      expect(result.current[1].error).toEqual(
+        new ApolloError({ graphQLErrors: [new GraphQLError("Oops")] })
+      );
+    });
+
+    // variables changed
+    expect(trackClosureValue).toHaveBeenNthCalledWith(1, "nextFetchPolicy", 2);
+    // after fetch
+    expect(trackClosureValue).toHaveBeenNthCalledWith(2, "nextFetchPolicy", 2);
+    expect(trackClosureValue).toHaveBeenNthCalledWith(3, "onError", 2);
+    trackClosureValue.mockClear();
+
+    countRef.current++;
+    rerender();
+
+    expect(result.current[0]).toBe(originalExecute);
+
+    await act(() => result.current[0]({ variables: { id: "3" } }));
+    await waitFor(() => {
+      expect(result.current[1].data).toEqual({
+        user: { id: "3", name: "Johnny Three" },
+      });
+    });
+
+    // variables changed
+    expect(trackClosureValue).toHaveBeenNthCalledWith(1, "nextFetchPolicy", 3);
+    // after fetch
+    expect(trackClosureValue).toHaveBeenNthCalledWith(2, "nextFetchPolicy", 3);
+    expect(trackClosureValue).toHaveBeenNthCalledWith(3, "onCompleted", 3);
+    trackClosureValue.mockClear();
+
+    // Test for stale closures for skipPollAttempt
+    result.current[1].startPolling(20);
+    await wait(50);
+    result.current[1].stopPolling();
+
+    expect(trackClosureValue).toHaveBeenCalledWith("skipPollAttempt", 3);
+  });
+
+  it("maintains stable execute function identity when changing non-callback options", async () => {
+    interface Data {
+      user: { id: string; name: string };
+    }
+
+    interface Variables {
+      id: string;
+    }
+
+    const query: TypedDocumentNode<Data, Variables> = gql`
+      query UserQuery($id: ID!) {
+        user(id: $id) {
+          id
+          name
+        }
+      }
+    `;
+
+    const link = new ApolloLink((operation) => {
+      return new Observable((observer) => {
+        setTimeout(() => {
+          observer.next({
+            data: { user: { id: operation.variables.id, name: "John Doe" } },
+          });
+          observer.complete();
+        }, 20);
+      });
+    });
+
+    const client = new ApolloClient({ link, cache: new InMemoryCache() });
+
+    const { result, rerender } = renderHook(
+      ({ id }) => useLazyQuery(query, { variables: { id } }),
+      {
+        initialProps: { id: "1" },
+        wrapper: ({ children }) => (
+          <ApolloProvider client={client}>{children}</ApolloProvider>
+        ),
+      }
+    );
+
+    const [execute] = result.current;
+
+    rerender({ id: "2" });
+
+    expect(result.current[0]).toBe(execute);
+  });
+
   describe("network errors", () => {
     async function check(errorPolicy: ErrorPolicy) {
       const networkError = new Error("from the network");