Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add ignoreResults option to useSubscription #11921

Merged
merged 11 commits into from
Jul 8, 2024
3 changes: 2 additions & 1 deletion .api-reports/api-report-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ export interface BaseSubscriptionOptions<TData = any, TVariables extends Operati
context?: Context;
// Warning: (ae-forgotten-export) The symbol "FetchPolicy" needs to be exported by the entry point index.d.ts
fetchPolicy?: FetchPolicy;
ignoreResults?: boolean;
onComplete?: () => void;
onData?: (options: OnDataOptions<TData>) => any;
onError?: (error: ApolloError) => void;
Expand Down Expand Up @@ -1915,7 +1916,7 @@ export interface SubscriptionCurrentObservable {
subscription?: Subscription;
}

// @public (undocumented)
// @public @deprecated (undocumented)
export interface SubscriptionDataOptions<TData = any, TVariables extends OperationVariables = OperationVariables> extends BaseSubscriptionOptions<TData, TVariables> {
// (undocumented)
children?: null | ((result: SubscriptionResult<TData>) => ReactTypes.ReactNode);
Expand Down
1 change: 1 addition & 0 deletions .api-reports/api-report-react_components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ interface BaseSubscriptionOptions<TData = any, TVariables extends OperationVaria
context?: DefaultContext;
// Warning: (ae-forgotten-export) The symbol "FetchPolicy" needs to be exported by the entry point index.d.ts
fetchPolicy?: FetchPolicy;
ignoreResults?: boolean;
onComplete?: () => void;
// Warning: (ae-forgotten-export) The symbol "OnDataOptions" needs to be exported by the entry point index.d.ts
onData?: (options: OnDataOptions<TData>) => any;
Expand Down
1 change: 1 addition & 0 deletions .api-reports/api-report-react_hooks.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ interface BaseSubscriptionOptions<TData = any, TVariables extends OperationVaria
context?: DefaultContext;
// Warning: (ae-forgotten-export) The symbol "FetchPolicy" needs to be exported by the entry point index.d.ts
fetchPolicy?: FetchPolicy;
ignoreResults?: boolean;
onComplete?: () => void;
// Warning: (ae-forgotten-export) The symbol "OnDataOptions" needs to be exported by the entry point index.d.ts
onData?: (options: OnDataOptions<TData>) => any;
Expand Down
3 changes: 2 additions & 1 deletion .api-reports/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ export interface BaseSubscriptionOptions<TData = any, TVariables extends Operati
client?: ApolloClient<object>;
context?: DefaultContext;
fetchPolicy?: FetchPolicy;
ignoreResults?: boolean;
onComplete?: () => void;
onData?: (options: OnDataOptions<TData>) => any;
onError?: (error: ApolloError) => void;
Expand Down Expand Up @@ -2547,7 +2548,7 @@ export interface SubscriptionCurrentObservable {
subscription?: ObservableSubscription;
}

// @public (undocumented)
// @public @deprecated (undocumented)
export interface SubscriptionDataOptions<TData = any, TVariables extends OperationVariables = OperationVariables> extends BaseSubscriptionOptions<TData, TVariables> {
// (undocumented)
children?: null | ((result: SubscriptionResult<TData>) => ReactTypes.ReactNode);
Expand Down
5 changes: 5 additions & 0 deletions .changeset/unlucky-birds-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

add `ignoreResults` option to `useSubscription`
2 changes: 1 addition & 1 deletion .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 39619,
"dist/apollo-client.min.cjs": 39641,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32852
}
276 changes: 273 additions & 3 deletions src/react/hooks/__tests__/useSubscription.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { render, renderHook, waitFor } from "@testing-library/react";
import gql from "graphql-tag";

import {
Expand All @@ -14,8 +14,8 @@ import { InMemoryCache as Cache } from "../../../cache";
import { ApolloProvider } from "../../context";
import { MockSubscriptionLink } from "../../../testing";
import { useSubscription } from "../useSubscription";
import { spyOnConsole } from "../../../testing/internal";

import { profileHook, spyOnConsole } from "../../../testing/internal";
import { SubscriptionHookOptions } from "../../types/types";
describe("useSubscription Hook", () => {
it("should handle a simple subscription properly", async () => {
const subscription = gql`
Expand Down Expand Up @@ -1122,6 +1122,276 @@ followed by new in-flight setup", async () => {
});
});

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"]
);
Comment on lines +1478 to +1482
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const onData = jest.fn((() => {}) as SubscriptionHookOptions["onData"]);
const onError = jest.fn((() => {}) as SubscriptionHookOptions["onError"]);
const onComplete = jest.fn(
(() => {}) as SubscriptionHookOptions["onComplete"]
);
const onData = jest.fn();
const onError = jest.fn();
const onComplete = jest.fn();

Out of curiosity, are these casts needed? I'm able to remove this entirely and see no type errors. If you're using this to convey intent, feel free to ignore this comment.

(this suggestion applies to any place with these mocks, but I'll avoid adding this comment to the other locations)

Copy link
Member Author

@phryneas phryneas Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for test-writing DX. If you hover onData further down in the test to get a feeling what you should assert on, you either get any, or with this the right type.

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,
});
link.simulateResult(results[0]);

await waitFor(() => {
expect(onData).toHaveBeenCalledTimes(1);
expect(onData.mock.lastCall?.[0].data).toStrictEqual({
data: results[0].result.data,
error: undefined,
loading: false,
variables: undefined,
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect(onData.mock.lastCall?.[0].data).toStrictEqual({
data: results[0].result.data,
error: undefined,
loading: false,
variables: undefined,
});
expect(onData).toHaveBeenLastCalledWith(
expect.objectContaining({
data: {
data: results[0].result.data,
error: undefined,
loading: false,
variables: undefined,
},
})
);

[nit] As a readability thing, it would be great if we could structure this using the expect helpers (toHaveBeenLastCalledWith) rather than trying to directly read from mock directly. I think you were looking to avoid checking against every property passed to that first argument, so perhaps using expect.objectContaining can also get around that. I'm able to get the test to pass the same structuring it this way. This also makes it clear to the reader that there are more properties passed to this argument that we aren't checking as a part of this test 🙂

(this suggestion applies to any location with .mocks.lastCall, so I'll avoid adding this to each place)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough - I think I had tried that before, but probably missed something :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expect(onError).toHaveBeenCalledTimes(0);
expect(onComplete).toHaveBeenCalledTimes(0);
});

link.simulateResult(results[1], true);
await waitFor(() => {
expect(onData).toHaveBeenCalledTimes(2);
expect(onData.mock.lastCall?.[0].data).toStrictEqual({
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,
});
link.simulateResult(results[0]);

await waitFor(() => {
expect(onData).toHaveBeenCalledTimes(1);
expect(onData.mock.lastCall?.[0].data).toStrictEqual({
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,
});
expect(onData).toHaveBeenCalledTimes(0);
}
link.simulateResult(results[0]);
await expect(ProfiledHook).not.toRerender({ timeout: 20 });

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,
});
// `onData` should not be called again for the same result
expect(onData).toHaveBeenCalledTimes(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you perhaps add one this assertion before the rerender call as well? This confused me at first because the last time you assert on the number of calls, its checking against 0, so it sort of looks like this function was called after you switch from ignoreResults true -> false. Adding the assertion before rerender makes this obvious that this count didn't increase as a result of rerendering the component and changing the ignoreResults option.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

link.simulateResult(results[1]);
{
const snapshot = await ProfiledHook.takeSnapshot();
expect(snapshot).toStrictEqual({
loading: false,
error: undefined,
data: results[1].result.data,
variables: undefined,
});
expect(onData).toHaveBeenCalledTimes(2);
}
// a second subscription should not have been started
expect(subscriptionCreated).toHaveBeenCalledTimes(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👨‍🍳 💋

});
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,
});
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,
});
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we return undefined when switching to ignoreResults: true rather than returning the result up to this point, we might want to make this clear in the docs somehow so that someone doesn't expect to continue getting a value here.

FWIW I think this is reasonable behavior since ignoreResults: true is a signifier that you don't care about the value returned from the hook, but I just think adding the extra clarification will help avoid confusion.

Copy link
Member Author

@phryneas phryneas Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

variables: undefined,
});
// `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 }>;
Expand Down
Loading
Loading