diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index d1cd5a93af3..d685c3f6599 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -388,6 +388,7 @@ export interface BaseSubscriptionOptions void; onData?: (options: OnDataOptions) => any; onError?: (error: ApolloError) => void; @@ -1919,7 +1920,7 @@ export interface SubscriptionCurrentObservable { subscription?: Subscription; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface SubscriptionDataOptions extends BaseSubscriptionOptions { // (undocumented) children?: null | ((result: SubscriptionResult) => ReactTypes.ReactNode); diff --git a/.api-reports/api-report-react_components.api.md b/.api-reports/api-report-react_components.api.md index efb4ddab230..0aff1af40cf 100644 --- a/.api-reports/api-report-react_components.api.md +++ b/.api-reports/api-report-react_components.api.md @@ -336,6 +336,7 @@ interface BaseSubscriptionOptions void; // Warning: (ae-forgotten-export) The symbol "OnDataOptions" needs to be exported by the entry point index.d.ts onData?: (options: OnDataOptions) => any; diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index cec9f2c4d1e..2dcd7bce461 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -359,6 +359,7 @@ interface BaseSubscriptionOptions void; // Warning: (ae-forgotten-export) The symbol "OnDataOptions" needs to be exported by the entry point index.d.ts onData?: (options: OnDataOptions) => any; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 11e76dd8d6f..007a3ba589b 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -359,6 +359,7 @@ export interface BaseSubscriptionOptions; context?: DefaultContext; fetchPolicy?: FetchPolicy; + ignoreResults?: boolean; onComplete?: () => void; onData?: (options: OnDataOptions) => any; onError?: (error: ApolloError) => void; @@ -2551,7 +2552,7 @@ export interface SubscriptionCurrentObservable { subscription?: ObservableSubscription; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface SubscriptionDataOptions extends BaseSubscriptionOptions { // (undocumented) children?: null | ((result: SubscriptionResult) => ReactTypes.ReactNode); diff --git a/.changeset/unlucky-birds-press.md b/.changeset/unlucky-birds-press.md new file mode 100644 index 00000000000..5696787576d --- /dev/null +++ b/.changeset/unlucky-birds-press.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +add `ignoreResults` option to `useSubscription` diff --git a/.size-limits.json b/.size-limits.json index 4e756f84c34..28452c40fd4 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39971, + "dist/apollo-client.min.cjs": 40015, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32903 } diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx index e955ae1e00c..eb02e41c9aa 100644 --- 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(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + 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(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + 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(, { + wrapper: ({ children }) => ( + {children} + ), + }); + 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(); + { + 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(, { + wrapper: ({ children }) => ( + {children} + ), + }); + 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(); + { + 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 }>; diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index b1f3a9a733b..d602578de73 100644 --- 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>( () => ({ - 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>( 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 index 186d651dfd8..c5f232c1b18 100644 --- 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 index 41cff9e8835..be799bf52dd 100644 --- 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 { @@ -479,6 +484,9 @@ export interface SubscriptionHookOptions< TVariables extends OperationVariables = OperationVariables, > extends BaseSubscriptionOptions {} +/** + * @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,