From 8b9bc7a104ac541f8f65d002ce4aad7aa5a62ed0 Mon Sep 17 00:00:00 2001 From: John Costello Date: Fri, 17 May 2024 10:58:04 -0400 Subject: [PATCH] Support extensions in useSubscription This adds optional support for extensions in useSubscription following the graphql-ws spec: https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md --- .api-reports/api-report-core.md | 3 +- .changeset/angry-seals-jog.md | 5 ++ src/core/QueryManager.ts | 72 ++++++++++--------- src/core/watchQueryOptions.ts | 3 + .../hooks/__tests__/useSubscription.test.tsx | 68 ++++++++++++++++++ src/react/hooks/useSubscription.ts | 2 + src/react/types/types.documentation.ts | 5 ++ src/react/types/types.ts | 2 + 8 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 .changeset/angry-seals-jog.md diff --git a/.api-reports/api-report-core.md b/.api-reports/api-report-core.md index de1328393e7..cad2505c81b 100644 --- a/.api-reports/api-report-core.md +++ b/.api-reports/api-report-core.md @@ -2124,12 +2124,13 @@ export type SubscribeToMoreOptions { +export interface SubscriptionOptions> { context?: DefaultContext; errorPolicy?: ErrorPolicy; fetchPolicy?: FetchPolicy; query: DocumentNode | TypedDocumentNode; variables?: TVariables; + extensions?: TExtensions; } // @public (undocumented) diff --git a/.changeset/angry-seals-jog.md b/.changeset/angry-seals-jog.md new file mode 100644 index 00000000000..9ba52ae2f3c --- /dev/null +++ b/.changeset/angry-seals-jog.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Support extensions in useSubscription diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 92029d9a6f1..9d230fcc3c0 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -293,6 +293,7 @@ export class QueryManager { optimisticResponse: isOptimistic ? optimisticResponse : void 0, }, variables, + {}, false ), @@ -981,52 +982,55 @@ export class QueryManager { errorPolicy = "none", variables, context = {}, + extensions = {}, }: SubscriptionOptions): Observable> { query = this.transform(query); variables = this.getVariables(query, variables); const makeObservable = (variables: OperationVariables) => - this.getObservableFromLink(query, context, variables).map((result) => { - if (fetchPolicy !== "no-cache") { - // the subscription interface should handle not sending us results we no longer subscribe to. - // XXX I don't think we ever send in an object with errors, but we might in the future... - if (shouldWriteResult(result, errorPolicy)) { - this.cache.write({ - query, - result: result.data, - dataId: "ROOT_SUBSCRIPTION", - variables: variables, - }); + this.getObservableFromLink(query, context, variables, extensions).map( + (result) => { + if (fetchPolicy !== "no-cache") { + // the subscription interface should handle not sending us results we no longer subscribe to. + // XXX I don't think we ever send in an object with errors, but we might in the future... + if (shouldWriteResult(result, errorPolicy)) { + this.cache.write({ + query, + result: result.data, + dataId: "ROOT_SUBSCRIPTION", + variables: variables, + }); + } + + this.broadcastQueries(); } - this.broadcastQueries(); - } + const hasErrors = graphQLResultHasError(result); + const hasProtocolErrors = graphQLResultHasProtocolErrors(result); + if (hasErrors || hasProtocolErrors) { + const errors: ApolloErrorOptions = {}; + if (hasErrors) { + errors.graphQLErrors = result.errors; + } + if (hasProtocolErrors) { + errors.protocolErrors = result.extensions[PROTOCOL_ERRORS_SYMBOL]; + } - const hasErrors = graphQLResultHasError(result); - const hasProtocolErrors = graphQLResultHasProtocolErrors(result); - if (hasErrors || hasProtocolErrors) { - const errors: ApolloErrorOptions = {}; - if (hasErrors) { - errors.graphQLErrors = result.errors; - } - if (hasProtocolErrors) { - errors.protocolErrors = result.extensions[PROTOCOL_ERRORS_SYMBOL]; + // `errorPolicy` is a mechanism for handling GraphQL errors, according + // to our documentation, so we throw protocol errors regardless of the + // set error policy. + if (errorPolicy === "none" || hasProtocolErrors) { + throw new ApolloError(errors); + } } - // `errorPolicy` is a mechanism for handling GraphQL errors, according - // to our documentation, so we throw protocol errors regardless of the - // set error policy. - if (errorPolicy === "none" || hasProtocolErrors) { - throw new ApolloError(errors); + if (errorPolicy === "ignore") { + delete result.errors; } - } - if (errorPolicy === "ignore") { - delete result.errors; + return result; } - - return result; - }); + ); if (this.getDocumentInfo(query).hasClientExports) { const observablePromise = this.localState @@ -1088,6 +1092,7 @@ export class QueryManager { query: DocumentNode, context: any, variables?: OperationVariables, + extensions?: Record, // Prefer context.queryDeduplication if specified. deduplication: boolean = context?.queryDeduplication ?? this.queryDeduplication @@ -1106,6 +1111,7 @@ export class QueryManager { ...context, forceFetch: !deduplication, }), + extensions, }; context = operation.context; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 5810c6464c4..b05cdb13c33 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -206,6 +206,9 @@ export interface SubscriptionOptions< /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#context:member} */ context?: DefaultContext; + + /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#extensions:member} */ + extensions?: Record; } export interface MutationBaseOptions< diff --git a/src/react/hooks/__tests__/useSubscription.test.tsx b/src/react/hooks/__tests__/useSubscription.test.tsx index decdd17b973..06538172113 100644 --- a/src/react/hooks/__tests__/useSubscription.test.tsx +++ b/src/react/hooks/__tests__/useSubscription.test.tsx @@ -454,6 +454,74 @@ describe("useSubscription Hook", () => { expect(context!).toBe("Audi"); }); + it("should share extensions set in options", async () => { + const subscription = gql` + subscription { + car { + make + } + } + `; + + const results = ["Audi", "BMW"].map((make) => ({ + result: { data: { car: { make } } }, + })); + + let extensions: string; + const link = new MockSubscriptionLink(); + const extensionsLink = new ApolloLink((operation, forward) => { + extensions = operation.extensions.make; + return forward(operation); + }); + const client = new ApolloClient({ + link: concat(extensionsLink, link), + cache: new Cache({ addTypename: false }), + }); + + const { result } = renderHook( + () => + useSubscription(subscription, { + extensions: { make: "Audi" }, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ); + + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(undefined); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult(results[0]); + }, 100); + + await waitFor( + () => { + expect(result.current.data).toEqual(results[0].result.data); + }, + { interval: 1 } + ); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + + setTimeout(() => { + link.simulateResult(results[1]); + }); + + await waitFor( + () => { + expect(result.current.data).toEqual(results[1].result.data); + }, + { interval: 1 } + ); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(undefined); + + expect(extensions!).toBe("Audi"); + }); + it("should handle multiple subscriptions properly", async () => { const subscription = gql` subscription { diff --git a/src/react/hooks/useSubscription.ts b/src/react/hooks/useSubscription.ts index 366ebfe97f4..c55afe51b21 100644 --- a/src/react/hooks/useSubscription.ts +++ b/src/react/hooks/useSubscription.ts @@ -146,6 +146,7 @@ export function useSubscription< variables: options?.variables, fetchPolicy: options?.fetchPolicy, context: options?.context, + extensions: options?.extensions, }); }); @@ -198,6 +199,7 @@ export function useSubscription< variables: options?.variables, fetchPolicy: options?.fetchPolicy, context: options?.context, + extensions: options?.extensions, }) ); canResetObservableRef.current = false; diff --git a/src/react/types/types.documentation.ts b/src/react/types/types.documentation.ts index 186d651dfd8..8ab8812e384 100644 --- a/src/react/types/types.documentation.ts +++ b/src/react/types/types.documentation.ts @@ -546,6 +546,11 @@ export interface SubscriptionOptionsDocumentation { */ context: unknown; + /** + * Shared context between your component and your network interface (Apollo Link). + */ + extensions: unknown; + /** * Allows the registration of a callback function that will be triggered each time the `useSubscription` Hook / `Subscription` component completes the subscription. * diff --git a/src/react/types/types.ts b/src/react/types/types.ts index 41cff9e8835..abe05420831 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -447,6 +447,8 @@ export interface BaseSubscriptionOptions< skip?: boolean; /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#context:member} */ context?: DefaultContext; + /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#extensions:member} */ + extensions?: Record; /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#onComplete:member} */ onComplete?: () => void; /** {@inheritDoc @apollo/client!SubscriptionOptionsDocumentation#onData:member} */