From 2941824dd66cdd20eee5f2293373ad7a9cf991a4 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 8 Jul 2024 11:38:11 +0200 Subject: [PATCH] Add `restart` function to `useSubscription` (#11927) * syntax adjustment for compiler * Add `restart` function to `useSubscription`. * add tests * adjust test timing to accomodate for React 17 * Apply suggestions from code review Co-authored-by: Jerel Miller * Clean up Prettier, Size-limit, and Api-Extractor --------- Co-authored-by: Jerel Miller Co-authored-by: phryneas --- .api-reports/api-report-react.api.md | 8 +- .api-reports/api-report-react_hooks.api.md | 8 +- .api-reports/api-report.api.md | 8 +- .changeset/clever-bikes-admire.md | 5 + .size-limits.json | 2 +- .../hooks/__tests__/useSubscription.test.tsx | 337 +++++++++++++++++- src/react/hooks/useSubscription.ts | 48 ++- 7 files changed, 394 insertions(+), 22 deletions(-) create mode 100644 .changeset/clever-bikes-admire.md diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index c81d938a887..d1cd5a93af3 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -2190,7 +2190,13 @@ export interface UseReadQueryResult { } // @public -export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): SubscriptionResult; +export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): { + restart(): void; + loading: boolean; + data?: TData | undefined; + error?: ApolloError; + variables?: TVariables | undefined; +}; // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index b5ee3e1c051..cec9f2c4d1e 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -2023,7 +2023,13 @@ export interface UseReadQueryResult { // Warning: (ae-forgotten-export) The symbol "SubscriptionHookOptions" needs to be exported by the entry point index.d.ts // // @public -export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): SubscriptionResult; +export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): { + restart(): void; + loading: boolean; + data?: TData | undefined; + error?: ApolloError; + variables?: TVariables | undefined; +}; // Warning: (ae-forgotten-export) The symbol "SuspenseQueryHookOptions" needs to be exported by the entry point index.d.ts // diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index fb2bec58947..11e76dd8d6f 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -2851,7 +2851,13 @@ export interface UseReadQueryResult { } // @public -export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): SubscriptionResult; +export function useSubscription(subscription: DocumentNode | TypedDocumentNode, options?: SubscriptionHookOptions, NoInfer_2>): { + restart(): void; + loading: boolean; + data?: TData | undefined; + error?: ApolloError; + variables?: TVariables | undefined; +}; // @public (undocumented) export function useSuspenseQuery, "variables">>(query: DocumentNode | TypedDocumentNode, options?: SuspenseQueryHookOptions, NoInfer_2> & TOptions): UseSuspenseQueryResult | undefined : TData | undefined : TOptions["returnPartialData"] extends true ? TOptions["skip"] extends boolean ? DeepPartial | undefined : DeepPartial : TOptions["skip"] extends boolean ? TData | undefined : TData, TVariables>; diff --git a/.changeset/clever-bikes-admire.md b/.changeset/clever-bikes-admire.md new file mode 100644 index 00000000000..36b9ba5de3a --- /dev/null +++ b/.changeset/clever-bikes-admire.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Add `restart` function to `useSubscription`. diff --git a/.size-limits.json b/.size-limits.json index c9a1233d358..4e756f84c34 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39924, + "dist/apollo-client.min.cjs": 39971, "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 decdd17b973..e955ae1e00c 100644 --- a/src/react/hooks/__tests__/useSubscription.test.tsx +++ b/src/react/hooks/__tests__/useSubscription.test.tsx @@ -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 { @@ -14,7 +14,10 @@ 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"; +import { GraphQLError } from "graphql"; +import { InvariantError } from "ts-invariant"; describe("useSubscription Hook", () => { it("should handle a simple subscription properly", async () => { @@ -1122,6 +1125,336 @@ followed by new in-flight setup", async () => { }); }); +describe("`restart` callback", () => { + function setup() { + const subscription: TypedDocumentNode< + { totalLikes: number }, + { id: string } + > = gql` + subscription ($id: ID!) { + totalLikes(postId: $id) + } + `; + const onSubscribe = jest.fn(); + const onUnsubscribe = jest.fn(); + const link = new MockSubscriptionLink(); + link.onSetup(onSubscribe); + link.onUnsubscribe(onUnsubscribe); + const client = new ApolloClient({ + link, + cache: new Cache(), + }); + const ProfiledHook = profileHook( + ( + options: SubscriptionHookOptions<{ totalLikes: number }, { id: string }> + ) => useSubscription(subscription, options) + ); + return { client, link, ProfiledHook, onSubscribe, onUnsubscribe }; + } + it("can restart a running subscription", async () => { + const { client, link, ProfiledHook, onSubscribe, onUnsubscribe } = setup(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + link.simulateResult({ result: { data: { totalLikes: 1 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 1 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await expect(ProfiledHook).not.toRerender({ timeout: 20 }); + expect(onUnsubscribe).toHaveBeenCalledTimes(0); + expect(onSubscribe).toHaveBeenCalledTimes(1); + + ProfiledHook.getCurrentSnapshot().restart(); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await waitFor(() => expect(onUnsubscribe).toHaveBeenCalledTimes(1)); + expect(onSubscribe).toHaveBeenCalledTimes(2); + + link.simulateResult({ result: { data: { totalLikes: 2 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 2 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + }); + it("will use the most recently passed in options", async () => { + const { client, link, ProfiledHook, onSubscribe, onUnsubscribe } = setup(); + const { rerender } = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + // deliberately keeping a reference to a very old `restart` function + // to show that the most recent options are used even with that + const restart = ProfiledHook.getCurrentSnapshot().restart; + link.simulateResult({ result: { data: { totalLikes: 1 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 1 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await expect(ProfiledHook).not.toRerender({ timeout: 20 }); + expect(onUnsubscribe).toHaveBeenCalledTimes(0); + expect(onSubscribe).toHaveBeenCalledTimes(1); + + rerender(); + await waitFor(() => expect(onUnsubscribe).toHaveBeenCalledTimes(1)); + expect(onSubscribe).toHaveBeenCalledTimes(2); + expect(link.operation?.variables).toStrictEqual({ id: "2" }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "2" }, + }); + } + link.simulateResult({ result: { data: { totalLikes: 1000 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 1000 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "2" }, + }); + } + + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + expect(onSubscribe).toHaveBeenCalledTimes(2); + expect(link.operation?.variables).toStrictEqual({ id: "2" }); + + restart(); + + await waitFor(() => expect(onUnsubscribe).toHaveBeenCalledTimes(2)); + expect(onSubscribe).toHaveBeenCalledTimes(3); + expect(link.operation?.variables).toStrictEqual({ id: "2" }); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "2" }, + }); + } + link.simulateResult({ result: { data: { totalLikes: 1005 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 1005 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "2" }, + }); + } + }); + it("can restart a subscription that has completed", async () => { + const { client, link, ProfiledHook, onSubscribe, onUnsubscribe } = setup(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + link.simulateResult({ result: { data: { totalLikes: 1 } } }, true); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 1 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await expect(ProfiledHook).not.toRerender({ timeout: 20 }); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + expect(onSubscribe).toHaveBeenCalledTimes(1); + + ProfiledHook.getCurrentSnapshot().restart(); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await waitFor(() => expect(onSubscribe).toHaveBeenCalledTimes(2)); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + + link.simulateResult({ result: { data: { totalLikes: 2 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 2 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + }); + it("can restart a subscription that has errored", async () => { + const { client, link, ProfiledHook, onSubscribe, onUnsubscribe } = setup(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + const error = new GraphQLError("error"); + link.simulateResult({ + result: { errors: [error] }, + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: undefined, + error: new ApolloError({ graphQLErrors: [error] }), + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await expect(ProfiledHook).not.toRerender({ timeout: 20 }); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + expect(onSubscribe).toHaveBeenCalledTimes(1); + + ProfiledHook.getCurrentSnapshot().restart(); + + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: true, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + await waitFor(() => expect(onSubscribe).toHaveBeenCalledTimes(2)); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + + link.simulateResult({ result: { data: { totalLikes: 2 } } }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: { totalLikes: 2 }, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + }); + it("will not restart a subscription that has been `skip`ped", async () => { + const { client, ProfiledHook, onSubscribe, onUnsubscribe } = setup(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + { + const snapshot = await ProfiledHook.takeSnapshot(); + expect(snapshot).toStrictEqual({ + loading: false, + data: undefined, + error: undefined, + restart: expect.any(Function), + variables: { id: "1" }, + }); + } + expect(onUnsubscribe).toHaveBeenCalledTimes(0); + expect(onSubscribe).toHaveBeenCalledTimes(0); + + expect(() => ProfiledHook.getCurrentSnapshot().restart()).toThrow( + new InvariantError("A subscription that is skipped cannot be restarted.") + ); + + await expect(ProfiledHook).not.toRerender({ timeout: 20 }); + expect(onUnsubscribe).toHaveBeenCalledTimes(0); + expect(onSubscribe).toHaveBeenCalledTimes(0); + }); +}); + 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 3b0d7f4303d..b1f3a9a733b 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -21,6 +21,7 @@ import { Observable } from "../../core/index.js"; import { useApolloClient } from "./useApolloClient.js"; import { useDeepMemo } from "./internal/useDeepMemo.js"; import { useSyncExternalStore } from "./useSyncExternalStore.js"; +import { useIsomorphicLayoutEffect } from "./internal/useIsomorphicLayoutEffect.js"; /** * > Refer to the [Subscriptions](https://www.apollographql.com/docs/react/data/subscriptions/) section for a more in-depth overview of `useSubscription`. @@ -146,6 +147,14 @@ export function useSubscription< ) ); + const recreate = () => + createSubscription(client, subscription, variables, fetchPolicy, context); + + const recreateRef = React.useRef(recreate); + useIsomorphicLayoutEffect(() => { + recreateRef.current = recreate; + }); + if (skip) { if (observable) { setObservable((observable = null)); @@ -160,15 +169,7 @@ export function useSubscription< !!shouldResubscribe(options!) : shouldResubscribe) !== false) ) { - setObservable( - (observable = createSubscription( - client, - subscription, - variables, - fetchPolicy, - context - )) - ); + setObservable((observable = recreate())); } const optionsRef = React.useRef(options); @@ -186,7 +187,7 @@ export function useSubscription< [skip, variables] ); - return useSyncExternalStore>( + const ret = useSyncExternalStore>( React.useCallback( (update) => { if (!observable) { @@ -262,6 +263,19 @@ export function useSubscription< ), () => (observable && !skip ? observable.__.result : fallbackResult) ); + return React.useMemo( + () => ({ + ...ret, + restart() { + invariant( + !optionsRef.current.skip, + "A subscription that is skipped cannot be restarted." + ); + setObservable(recreateRef.current()); + }, + }), + [ret] + ); } function createSubscription< @@ -295,12 +309,14 @@ function createSubscription< new Observable>((observer) => { // lazily start the subscription when the first observer subscribes // to get around strict mode - observable ||= client.subscribe({ - query, - variables, - fetchPolicy, - context, - }); + if (!observable) { + observable = client.subscribe({ + query, + variables, + fetchPolicy, + context, + }); + } const sub = observable.subscribe(observer); return () => sub.unsubscribe(); }),