Repository: apollographql/apollo-client

Problem statement:
Add an `ignoreResults` option to the `useSubscription` API
### Overview 

Some issues have been brought to our attention that the `useSubscription` [API](https://www.apollographql.com/docs/react/data/subscriptions#options) doesn't have an option of [ignoreResults](https://www.apollographql.com/docs/react/data/mutations#ignoreresults), similar to the `useMutation` [API](https://www.apollographql.com/docs/react/data/mutations#options).  

### Issue 
Unfortunately, unlike `useMutation`, there is no "magic parameter" like `ignoreResults`, and a component will be rendered each time you get new data on subscription. `useSubscription` returns a value, and because it does, that means we need to update it, which requires components to re-render. Whereas `useMutation` allows someone to avoid the re-renders by passing an` ignoreResults` option.   

### Purpose
We could offer an `ignoreResults` option in `useSubscription` which would not return a value from `useSubscription` thereby avoiding re-renders This would be useful to allow a developer to  to not re-render a component on certain occasions if they wish.

### Details

- **Name**: ignoreResults
- **Type** boolean
- **Functionality:**  If true, the subscription's data property is not updated with the subscription's result.
- **Default:** false
	
### References

- https://stackoverflow.com/questions/61876931/how-to-prevent-re-rendering-with-usesubscription
- https://dagster.io/blog/web-workers-performance-issue
- https://medium.com/@seniv/improve-performance-of-your-react-apollo-application-440692e37026



Hints:
+10000

Patch:
diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts
--- a/src/react/hooks/useSubscription.ts
+++ b/src/react/hooks/useSubscription.ts
@@ -138,7 +138,8 @@ export function useSubscription<
     }
   }
 
-  const { skip, fetchPolicy, shouldResubscribe, context } = options;
+  const { skip, fetchPolicy, shouldResubscribe, context, ignoreResults } =
+    options;
   const variables = useDeepMemo(() => options.variables, [options.variables]);
 
   let [observable, setObservable] = React.useState(() =>
@@ -177,16 +178,30 @@ export function useSubscription<
     optionsRef.current = options;
   });
 
+  const fallbackLoading = !skip && !ignoreResults;
   const fallbackResult = React.useMemo<SubscriptionResult<TData, TVariables>>(
     () => ({
-      loading: !skip,
+      loading: fallbackLoading,
       error: void 0,
       data: void 0,
       variables,
     }),
-    [skip, variables]
+    [fallbackLoading, variables]
   );
 
+  const ignoreResultsRef = React.useRef(ignoreResults);
+  useIsomorphicLayoutEffect(() => {
+    // We cannot reference `ignoreResults` directly in the effect below
+    // it would add a dependency to the `useEffect` deps array, which means the
+    // subscription would be recreated if `ignoreResults` changes
+    // As a result, on resubscription, the last result would be re-delivered,
+    // rendering the component one additional time, and re-triggering `onData`.
+    // The same applies to `fetchPolicy`, which results in a new `observable`
+    // being created. We cannot really avoid it in that case, but we can at least
+    // avoid it for `ignoreResults`.
+    ignoreResultsRef.current = ignoreResults;
+  });
+
   const ret = useSyncExternalStore<SubscriptionResult<TData, TVariables>>(
     React.useCallback(
       (update) => {
@@ -212,7 +227,7 @@ export function useSubscription<
               variables,
             };
             observable.__.setResult(result);
-            update();
+            if (!ignoreResultsRef.current) update();
 
             if (optionsRef.current.onData) {
               optionsRef.current.onData({
@@ -234,7 +249,7 @@ export function useSubscription<
                 error,
                 variables,
               });
-              update();
+              if (!ignoreResultsRef.current) update();
               optionsRef.current.onError?.(error);
             }
           },
@@ -261,7 +276,10 @@ export function useSubscription<
       },
       [observable]
     ),
-    () => (observable && !skip ? observable.__.result : fallbackResult)
+    () =>
+      observable && !skip && !ignoreResults ?
+        observable.__.result
+      : fallbackResult
   );
   return React.useMemo(
     () => ({
diff --git a/src/react/types/types.documentation.ts b/src/react/types/types.documentation.ts
--- a/src/react/types/types.documentation.ts
+++ b/src/react/types/types.documentation.ts
@@ -531,6 +531,12 @@ export interface SubscriptionOptionsDocumentation {
    */
   shouldResubscribe: unknown;
 
+  /**
+   * If `true`, the hook will not cause the component to rerender. This is useful when you want to control the rendering of your component yourself with logic in the `onData` and `onError` callbacks.
+   *
+   * Changing this to `true` when the hook already has `data` will reset the `data` to `undefined`.
+   */
+  ignoreResults: unknown;
   /**
    * An `ApolloClient` instance. By default `useSubscription` / `Subscription` uses the client passed down via context, but a different client can be passed in.
    */
diff --git a/src/react/types/types.ts b/src/react/types/types.ts
--- a/src/react/types/types.ts
+++ b/src/react/types/types.ts
@@ -457,6 +457,11 @@ export interface BaseSubscriptionOptions<
   onError?: (error: ApolloError) => void;
   /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#onSubscriptionComplete:member} */
   onSubscriptionComplete?: () => void;
+  /**
+   * {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#ignoreResults:member}
+   * @defaultValue `false`
+   */
+  ignoreResults?: boolean;
 }
 
 export interface SubscriptionResult<TData = any, TVariables = any> {
@@ -479,6 +484,9 @@ export interface SubscriptionHookOptions<
   TVariables extends OperationVariables = OperationVariables,
 > extends BaseSubscriptionOptions<TData, TVariables> {}
 
+/**
+ * @deprecated This type is not used anymore. It will be removed in the next major version of Apollo Client
+ */
 export interface SubscriptionDataOptions<
   TData = any,
   TVariables extends OperationVariables = OperationVariables,


Test patch:
diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx
--- a/src/react/hooks/__tests__/useSubscription.test.tsx
+++ b/src/react/hooks/__tests__/useSubscription.test.tsx
@@ -1455,6 +1455,297 @@ describe("`restart` callback", () => {
   });
 });
 
+describe("ignoreResults", () => {
+  const subscription = gql`
+    subscription {
+      car {
+        make
+      }
+    }
+  `;
+
+  const results = ["Audi", "BMW"].map((make) => ({
+    result: { data: { car: { make } } },
+  }));
+
+  it("should not rerender when ignoreResults is true, but will call `onData` and `onComplete`", async () => {
+    const link = new MockSubscriptionLink();
+    const client = new ApolloClient({
+      link,
+      cache: new Cache({ addTypename: false }),
+    });
+
+    const onData = jest.fn((() => {}) as SubscriptionHookOptions["onData"]);
+    const onError = jest.fn((() => {}) as SubscriptionHookOptions["onError"]);
+    const onComplete = jest.fn(
+      (() => {}) as SubscriptionHookOptions["onComplete"]
+    );
+    const ProfiledHook = profileHook(() =>
+      useSubscription(subscription, {
+        ignoreResults: true,
+        onData,
+        onError,
+        onComplete,
+      })
+    );
+    render(<ProfiledHook />, {
+      wrapper: ({ children }) => (
+        <ApolloProvider client={client}>{children}</ApolloProvider>
+      ),
+    });
+
+    const snapshot = await ProfiledHook.takeSnapshot();
+    expect(snapshot).toStrictEqual({
+      loading: false,
+      error: undefined,
+      data: undefined,
+      variables: undefined,
+      restart: expect.any(Function),
+    });
+    link.simulateResult(results[0]);
+
+    await waitFor(() => {
+      expect(onData).toHaveBeenCalledTimes(1);
+      expect(onData).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          data: {
+            data: results[0].result.data,
+            error: undefined,
+            loading: false,
+            variables: undefined,
+          },
+        })
+      );
+      expect(onError).toHaveBeenCalledTimes(0);
+      expect(onComplete).toHaveBeenCalledTimes(0);
+    });
+
+    link.simulateResult(results[1], true);
+    await waitFor(() => {
+      expect(onData).toHaveBeenCalledTimes(2);
+      expect(onData).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          data: {
+            data: results[1].result.data,
+            error: undefined,
+            loading: false,
+            variables: undefined,
+          },
+        })
+      );
+      expect(onError).toHaveBeenCalledTimes(0);
+      expect(onComplete).toHaveBeenCalledTimes(1);
+    });
+
+    await expect(ProfiledHook).not.toRerender();
+  });
+
+  it("should not rerender when ignoreResults is true and an error occurs", async () => {
+    const link = new MockSubscriptionLink();
+    const client = new ApolloClient({
+      link,
+      cache: new Cache({ addTypename: false }),
+    });
+
+    const onData = jest.fn((() => {}) as SubscriptionHookOptions["onData"]);
+    const onError = jest.fn((() => {}) as SubscriptionHookOptions["onError"]);
+    const onComplete = jest.fn(
+      (() => {}) as SubscriptionHookOptions["onComplete"]
+    );
+    const ProfiledHook = profileHook(() =>
+      useSubscription(subscription, {
+        ignoreResults: true,
+        onData,
+        onError,
+        onComplete,
+      })
+    );
+    render(<ProfiledHook />, {
+      wrapper: ({ children }) => (
+        <ApolloProvider client={client}>{children}</ApolloProvider>
+      ),
+    });
+
+    const snapshot = await ProfiledHook.takeSnapshot();
+    expect(snapshot).toStrictEqual({
+      loading: false,
+      error: undefined,
+      data: undefined,
+      variables: undefined,
+      restart: expect.any(Function),
+    });
+    link.simulateResult(results[0]);
+
+    await waitFor(() => {
+      expect(onData).toHaveBeenCalledTimes(1);
+      expect(onData).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          data: {
+            data: results[0].result.data,
+            error: undefined,
+            loading: false,
+            variables: undefined,
+          },
+        })
+      );
+      expect(onError).toHaveBeenCalledTimes(0);
+      expect(onComplete).toHaveBeenCalledTimes(0);
+    });
+
+    const error = new Error("test");
+    link.simulateResult({ error });
+    await waitFor(() => {
+      expect(onData).toHaveBeenCalledTimes(1);
+      expect(onError).toHaveBeenCalledTimes(1);
+      expect(onError).toHaveBeenLastCalledWith(error);
+      expect(onComplete).toHaveBeenCalledTimes(0);
+    });
+
+    await expect(ProfiledHook).not.toRerender();
+  });
+
+  it("can switch from `ignoreResults: true` to `ignoreResults: false` and will start rerendering, without creating a new subscription", async () => {
+    const subscriptionCreated = jest.fn();
+    const link = new MockSubscriptionLink();
+    link.onSetup(subscriptionCreated);
+    const client = new ApolloClient({
+      link,
+      cache: new Cache({ addTypename: false }),
+    });
+
+    const onData = jest.fn((() => {}) as SubscriptionHookOptions["onData"]);
+    const ProfiledHook = profileHook(
+      ({ ignoreResults }: { ignoreResults: boolean }) =>
+        useSubscription(subscription, {
+          ignoreResults,
+          onData,
+        })
+    );
+    const { rerender } = render(<ProfiledHook ignoreResults={true} />, {
+      wrapper: ({ children }) => (
+        <ApolloProvider client={client}>{children}</ApolloProvider>
+      ),
+    });
+    expect(subscriptionCreated).toHaveBeenCalledTimes(1);
+
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: false,
+        error: undefined,
+        data: undefined,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      expect(onData).toHaveBeenCalledTimes(0);
+    }
+    link.simulateResult(results[0]);
+    await expect(ProfiledHook).not.toRerender({ timeout: 20 });
+    expect(onData).toHaveBeenCalledTimes(1);
+
+    rerender(<ProfiledHook ignoreResults={false} />);
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: false,
+        error: undefined,
+        // `data` appears immediately after changing to `ignoreResults: false`
+        data: results[0].result.data,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      // `onData` should not be called again for the same result
+      expect(onData).toHaveBeenCalledTimes(1);
+    }
+
+    link.simulateResult(results[1]);
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: false,
+        error: undefined,
+        data: results[1].result.data,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      expect(onData).toHaveBeenCalledTimes(2);
+    }
+    // a second subscription should not have been started
+    expect(subscriptionCreated).toHaveBeenCalledTimes(1);
+  });
+  it("can switch from `ignoreResults: false` to `ignoreResults: true` and will stop rerendering, without creating a new subscription", async () => {
+    const subscriptionCreated = jest.fn();
+    const link = new MockSubscriptionLink();
+    link.onSetup(subscriptionCreated);
+    const client = new ApolloClient({
+      link,
+      cache: new Cache({ addTypename: false }),
+    });
+
+    const onData = jest.fn((() => {}) as SubscriptionHookOptions["onData"]);
+    const ProfiledHook = profileHook(
+      ({ ignoreResults }: { ignoreResults: boolean }) =>
+        useSubscription(subscription, {
+          ignoreResults,
+          onData,
+        })
+    );
+    const { rerender } = render(<ProfiledHook ignoreResults={false} />, {
+      wrapper: ({ children }) => (
+        <ApolloProvider client={client}>{children}</ApolloProvider>
+      ),
+    });
+    expect(subscriptionCreated).toHaveBeenCalledTimes(1);
+
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: true,
+        error: undefined,
+        data: undefined,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      expect(onData).toHaveBeenCalledTimes(0);
+    }
+    link.simulateResult(results[0]);
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: false,
+        error: undefined,
+        data: results[0].result.data,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      expect(onData).toHaveBeenCalledTimes(1);
+    }
+    await expect(ProfiledHook).not.toRerender({ timeout: 20 });
+
+    rerender(<ProfiledHook ignoreResults={true} />);
+    {
+      const snapshot = await ProfiledHook.takeSnapshot();
+      expect(snapshot).toStrictEqual({
+        loading: false,
+        error: undefined,
+        // switching back to the default `ignoreResults: true` return value
+        data: undefined,
+        variables: undefined,
+        restart: expect.any(Function),
+      });
+      // `onData` should not be called again
+      expect(onData).toHaveBeenCalledTimes(1);
+    }
+
+    link.simulateResult(results[1]);
+    await expect(ProfiledHook).not.toRerender({ timeout: 20 });
+    expect(onData).toHaveBeenCalledTimes(2);
+
+    // a second subscription should not have been started
+    expect(subscriptionCreated).toHaveBeenCalledTimes(1);
+  });
+});
+
 describe.skip("Type Tests", () => {
   test("NoInfer prevents adding arbitrary additional variables", () => {
     const typedNode = {} as TypedDocumentNode<{ foo: string }, { bar: number }>;