From d88c7f8909e3cb31532e8b1fc7dd06be12f35591 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 5 Jul 2024 10:27:55 -0600 Subject: [PATCH] Add `subscribeToMore` function to `useBackgroundQuery`, `useQueryRefHandlers`, and `useLoadableQuery` (#11923) --- .api-reports/api-report-react.api.md | 12 +- .api-reports/api-report-react_hooks.api.md | 12 +- .api-reports/api-report-react_internal.api.md | 9 +- .api-reports/api-report.api.md | 12 +- .changeset/angry-ravens-mate.md | 5 + .changeset/chilly-dots-shake.md | 5 + .changeset/slimy-balloons-cheat.md | 5 + .size-limits.json | 4 +- src/core/ObservableQuery.ts | 2 + .../__tests__/useBackgroundQuery.test.tsx | 137 ++++++++++- .../hooks/__tests__/useLoadableQuery.test.tsx | 221 +++++++++++++++++- .../__tests__/useQueryRefHandlers.test.tsx | 155 +++++++++++- src/react/hooks/useBackgroundQuery.ts | 16 +- src/react/hooks/useLoadableQuery.ts | 23 +- src/react/hooks/useQueryRefHandlers.ts | 14 +- src/react/hooks/useSuspenseQuery.ts | 8 +- 16 files changed, 602 insertions(+), 38 deletions(-) create mode 100644 .changeset/angry-ravens-mate.md create mode 100644 .changeset/chilly-dots-shake.md create mode 100644 .changeset/slimy-balloons-cheat.md diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 74721b26f76..368a7fdc53f 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -2089,6 +2089,7 @@ UseBackgroundQueryResult // @public (undocumented) export type UseBackgroundQueryResult = { + subscribeToMore: SubscribeToMoreFunction; fetchMore: FetchMoreFunction; refetch: RefetchFunction; }; @@ -2148,6 +2149,7 @@ queryRef: QueryRef | null, handlers: { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; reset: ResetFunction; } ]; @@ -2165,6 +2167,7 @@ export function useQueryRefHandlers { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; } // Warning: (ae-forgotten-export) The symbol "ReactiveVar" needs to be exported by the entry point index.d.ts @@ -2240,8 +2243,6 @@ export interface UseSuspenseQueryResult; - // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts - // // (undocumented) subscribeToMore: SubscribeToMoreFunction; } @@ -2305,9 +2306,10 @@ interface WatchQueryOptions // @public (undocumented) export type UseBackgroundQueryResult = { + subscribeToMore: SubscribeToMoreFunction; fetchMore: FetchMoreFunction; refetch: RefetchFunction; }; @@ -1976,6 +1977,7 @@ queryRef: QueryRef | null, handlers: { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; reset: ResetFunction; } ]; @@ -1996,6 +1998,7 @@ export function useQueryRefHandlers { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; } // Warning: (ae-forgotten-export) The symbol "ReactiveVar" needs to be exported by the entry point index.d.ts @@ -2075,8 +2078,6 @@ export interface UseSuspenseQueryResult; - // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts - // // (undocumented) subscribeToMore: SubscribeToMoreFunction; } @@ -2129,9 +2130,10 @@ interface WatchQueryOptions // @public (undocumented) type UseBackgroundQueryResult = { + subscribeToMore: SubscribeToMoreFunction; fetchMore: FetchMoreFunction; refetch: RefetchFunction; }; @@ -1967,6 +1968,7 @@ function useQueryRefHandlers { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; } // Warning: (ae-forgotten-export) The symbol "UseReadQueryResult" needs to be exported by the entry point index.d.ts @@ -2039,8 +2041,6 @@ interface UseSuspenseQueryResult; - // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts - // // (undocumented) subscribeToMore: SubscribeToMoreFunction; } @@ -2134,8 +2134,9 @@ export function wrapQueryRef(inter // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:269:2 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:269:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:29:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:30:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:54:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:78:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 95cc0a4694b..4acdff1d7da 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -2752,6 +2752,7 @@ UseBackgroundQueryResult // @public (undocumented) export type UseBackgroundQueryResult = { + subscribeToMore: SubscribeToMoreFunction; fetchMore: FetchMoreFunction; refetch: RefetchFunction; }; @@ -2811,6 +2812,7 @@ queryRef: QueryRef | null, handlers: { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; reset: ResetFunction; } ]; @@ -2828,6 +2830,7 @@ export function useQueryRefHandlers { fetchMore: FetchMoreFunction; refetch: RefetchFunction; + subscribeToMore: SubscribeToMoreFunction; } // @public @@ -2901,8 +2904,6 @@ export interface UseSuspenseQueryResult; - // Warning: (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts - // // (undocumented) subscribeToMore: SubscribeToMoreFunction; } @@ -2994,9 +2995,10 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/watchQueryOptions.ts:269:2 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:269:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:29:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:30:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useLoadableQuery.ts:107:1 - (ae-forgotten-export) The symbol "ResetFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:54:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:78:4 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useLoadableQuery.ts:120:9 - (ae-forgotten-export) The symbol "ResetFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.changeset/angry-ravens-mate.md b/.changeset/angry-ravens-mate.md new file mode 100644 index 00000000000..3072009aff1 --- /dev/null +++ b/.changeset/angry-ravens-mate.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Add support for `subscribeToMore` function to `useQueryRefHandlers`. diff --git a/.changeset/chilly-dots-shake.md b/.changeset/chilly-dots-shake.md new file mode 100644 index 00000000000..0bbb1de7e58 --- /dev/null +++ b/.changeset/chilly-dots-shake.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Add support for `subscribeToMore` function to `useLoadableQuery`. diff --git a/.changeset/slimy-balloons-cheat.md b/.changeset/slimy-balloons-cheat.md new file mode 100644 index 00000000000..72291902106 --- /dev/null +++ b/.changeset/slimy-balloons-cheat.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Add support for `subscribeToMore` function to `useBackgroundQuery`. diff --git a/.size-limits.json b/.size-limits.json index 79e71c06997..81ed0bcf995 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39825, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32851 + "dist/apollo-client.min.cjs": 39873, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32865 } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 47deef22483..f9e6dd6b1e4 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -163,6 +163,8 @@ export class ObservableQuery< this.waitForOwnResult = skipCacheDataFor(options.fetchPolicy); this.isTornDown = false; + this.subscribeToMore = this.subscribeToMore.bind(this); + const { watchQuery: { fetchPolicy: defaultFetchPolicy = "cache-first" } = {}, } = queryManager.defaultOptions; diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index 91d142a4df5..ac0ef98b87a 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -16,6 +16,7 @@ import { TypedDocumentNode, ApolloLink, Observable, + split, } from "../../../core"; import { MockedResponse, @@ -29,6 +30,7 @@ import { concatPagination, offsetLimitPagination, DeepPartial, + getMainDefinition, } from "../../../utilities"; import { useBackgroundQuery } from "../useBackgroundQuery"; import { UseReadQueryResult, useReadQuery } from "../useReadQuery"; @@ -37,7 +39,10 @@ import { QueryRef, QueryReference } from "../../internal"; import { InMemoryCache } from "../../../cache"; import { SuspenseQueryHookFetchPolicy } from "../../types/types"; import equal from "@wry/equality"; -import { RefetchWritePolicy } from "../../../core/watchQueryOptions"; +import { + RefetchWritePolicy, + SubscribeToMoreOptions, +} from "../../../core/watchQueryOptions"; import { skipToken } from "../constants"; import { PaginatedCaseData, @@ -54,6 +59,7 @@ import { spyOnConsole, useTrackRenders, } from "../../../testing/internal"; +import { SubscribeToMoreFunction } from "../useSuspenseQuery"; afterEach(() => { jest.useRealTimers(); @@ -6052,6 +6058,135 @@ describe("fetchMore", () => { await expect(Profiler).not.toRerender(); }); + + it("can subscribe to subscriptions and react to cache updates via `subscribeToMore`", async () => { + interface SubscriptionData { + greetingUpdated: string; + } + + type UpdateQueryFn = NonNullable< + SubscribeToMoreOptions< + SimpleCaseData, + Record, + SubscriptionData + >["updateQuery"] + >; + + const subscription: TypedDocumentNode< + SubscriptionData, + Record + > = gql` + subscription { + greetingUpdated + } + `; + + const { mocks, query } = setupSimpleCase(); + + const wsLink = new MockSubscriptionLink(); + const mockLink = new MockLink(mocks); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + mockLink + ); + + const client = new ApolloClient({ link, cache: new InMemoryCache() }); + + const Profiler = createProfiler({ + initialSnapshot: { + subscribeToMore: null as SubscribeToMoreFunction< + SimpleCaseData, + Record + > | null, + result: null as UseReadQueryResult | null, + }, + }); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultTrackedComponents(Profiler); + + function App() { + useTrackRenders(); + const [queryRef, { subscribeToMore }] = useBackgroundQuery(query); + + Profiler.mergeSnapshot({ subscribeToMore }); + + return ( + }> + + + ); + } + + renderWithClient(, { client, wrapper: Profiler }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { renderedComponents, snapshot } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + const updateQuery = jest.fn< + ReturnType, + Parameters + >((_, { subscriptionData: { data } }) => { + return { greeting: data.greetingUpdated }; + }); + + const { snapshot } = Profiler.getCurrentRender(); + + snapshot.subscribeToMore!({ document: subscription, updateQuery }); + + wsLink.simulateResult({ + result: { + data: { + greetingUpdated: "Subscription hello", + }, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Subscription hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(updateQuery).toHaveBeenCalledTimes(1); + expect(updateQuery).toHaveBeenCalledWith( + { greeting: "Hello" }, + { + subscriptionData: { + data: { greetingUpdated: "Subscription hello" }, + }, + variables: {}, + } + ); + }); }); describe.skip("type tests", () => { diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index a5e97ca52e8..9c83a0bd6c5 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -22,6 +22,8 @@ import { Observable, OperationVariables, RefetchWritePolicy, + SubscribeToMoreOptions, + split, } from "../../../core"; import { MockedProvider, @@ -35,6 +37,7 @@ import { concatPagination, offsetLimitPagination, DeepPartial, + getMainDefinition, } from "../../../utilities"; import { useLoadableQuery } from "../useLoadableQuery"; import type { UseReadQueryResult } from "../useReadQuery"; @@ -43,7 +46,11 @@ import { ApolloProvider } from "../../context"; import { InMemoryCache } from "../../../cache"; import { LoadableQueryHookFetchPolicy } from "../../types/types"; import { QueryRef } from "../../../react"; -import { FetchMoreFunction, RefetchFunction } from "../useSuspenseQuery"; +import { + FetchMoreFunction, + RefetchFunction, + SubscribeToMoreFunction, +} from "../useSuspenseQuery"; import invariant, { InvariantError } from "ts-invariant"; import { Profiler, @@ -4667,6 +4674,218 @@ it("allows loadQuery to be called in useEffect on first render", async () => { expect(() => renderWithMocks(, { mocks })).not.toThrow(); }); +it("can subscribe to subscriptions and react to cache updates via `subscribeToMore`", async () => { + interface SubscriptionData { + greetingUpdated: string; + } + + type UpdateQueryFn = NonNullable< + SubscribeToMoreOptions< + SimpleCaseData, + Record, + SubscriptionData + >["updateQuery"] + >; + + const subscription: TypedDocumentNode< + SubscriptionData, + Record + > = gql` + subscription { + greetingUpdated + } + `; + + const { mocks, query } = setupSimpleCase(); + + const wsLink = new MockSubscriptionLink(); + const mockLink = new MockLink(mocks); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + mockLink + ); + + const client = new ApolloClient({ link, cache: new InMemoryCache() }); + + const Profiler = createProfiler({ + initialSnapshot: { + subscribeToMore: null as SubscribeToMoreFunction< + SimpleCaseData, + Record + > | null, + result: null as UseReadQueryResult | null, + }, + }); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { subscribeToMore }] = useLoadableQuery(query); + + Profiler.mergeSnapshot({ subscribeToMore }); + + return ( +
+ + }> + {queryRef && } + +
+ ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + // initial render + await Profiler.takeRender(); + + await act(() => user.click(screen.getByText("Load query"))); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { renderedComponents, snapshot } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + const updateQuery = jest.fn< + ReturnType, + Parameters + >((_, { subscriptionData: { data } }) => { + return { greeting: data.greetingUpdated }; + }); + + const { snapshot } = Profiler.getCurrentRender(); + + snapshot.subscribeToMore!({ document: subscription, updateQuery }); + + wsLink.simulateResult({ + result: { + data: { + greetingUpdated: "Subscription hello", + }, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Subscription hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(updateQuery).toHaveBeenCalledTimes(1); + expect(updateQuery).toHaveBeenCalledWith( + { greeting: "Hello" }, + { + subscriptionData: { + data: { greetingUpdated: "Subscription hello" }, + }, + variables: {}, + } + ); +}); + +it("throws when calling `subscribeToMore` before loading the query", async () => { + interface SubscriptionData { + greetingUpdated: string; + } + + const subscription: TypedDocumentNode< + SubscriptionData, + Record + > = gql` + subscription { + greetingUpdated + } + `; + + const { mocks, query } = setupSimpleCase(); + + const wsLink = new MockSubscriptionLink(); + const mockLink = new MockLink(mocks); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + mockLink + ); + + const client = new ApolloClient({ link, cache: new InMemoryCache() }); + + const Profiler = createProfiler({ + initialSnapshot: { + subscribeToMore: null as SubscribeToMoreFunction< + SimpleCaseData, + Record + > | null, + result: null as UseReadQueryResult | null, + }, + }); + + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [loadQuery, queryRef, { subscribeToMore }] = useLoadableQuery(query); + + Profiler.mergeSnapshot({ subscribeToMore }); + + return ( +
+ + }> + {queryRef && } + +
+ ); + } + + renderWithClient(, { client, wrapper: Profiler }); + // initial render + await Profiler.takeRender(); + + const { snapshot } = Profiler.getCurrentRender(); + + expect(() => { + snapshot.subscribeToMore!({ document: subscription }); + }).toThrow( + new InvariantError("The query has not been loaded. Please load the query.") + ); +}); + describe.skip("type tests", () => { it("returns unknown when TData cannot be inferred", () => { const query = gql``; diff --git a/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx b/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx index 012f7fb1872..536d8ca2edb 100644 --- a/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx +++ b/src/react/hooks/__tests__/useQueryRefHandlers.test.tsx @@ -4,10 +4,16 @@ import { ApolloClient, InMemoryCache, NetworkStatus, + SubscribeToMoreOptions, TypedDocumentNode, gql, + split, } from "../../../core"; -import { MockLink, MockedResponse } from "../../../testing"; +import { + MockLink, + MockSubscriptionLink, + MockedResponse, +} from "../../../testing"; import { PaginatedCaseData, SimpleCaseData, @@ -19,13 +25,14 @@ import { } from "../../../testing/internal"; import { useQueryRefHandlers } from "../useQueryRefHandlers"; import { UseReadQueryResult, useReadQuery } from "../useReadQuery"; +import type { SubscribeToMoreFunction } from "../useSuspenseQuery"; import { Suspense } from "react"; import { createQueryPreloader } from "../../query-preloader/createQueryPreloader"; import userEvent from "@testing-library/user-event"; import { QueryRef } from "../../internal"; import { useBackgroundQuery } from "../useBackgroundQuery"; import { useLoadableQuery } from "../useLoadableQuery"; -import { concatPagination } from "../../../utilities"; +import { concatPagination, getMainDefinition } from "../../../utilities"; test("does not interfere with updates from useReadQuery", async () => { const { query, mocks } = setupSimpleCase(); @@ -1927,3 +1934,147 @@ test("`fetchMore` works with startTransition from useBackgroundQuery and useQuer await expect(Profiler).not.toRerender(); }); + +test("can subscribe to subscriptions and react to cache updates via `subscribeToMore`", async () => { + interface SubscriptionData { + greetingUpdated: string; + } + + type UpdateQueryFn = NonNullable< + SubscribeToMoreOptions< + SimpleCaseData, + Record, + SubscriptionData + >["updateQuery"] + >; + + const subscription: TypedDocumentNode< + SubscriptionData, + Record + > = gql` + subscription { + greetingUpdated + } + `; + + const { mocks, query } = setupSimpleCase(); + + const wsLink = new MockSubscriptionLink(); + const mockLink = new MockLink(mocks); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + mockLink + ); + + const client = new ApolloClient({ link, cache: new InMemoryCache() }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + const Profiler = createProfiler({ + initialSnapshot: { + subscribeToMore: null as SubscribeToMoreFunction< + SimpleCaseData, + Record + > | null, + result: null as UseReadQueryResult | null, + }, + }); + + function SuspenseFallback() { + useTrackRenders(); + return

Loading

; + } + + function ReadQueryHook() { + useTrackRenders(); + Profiler.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function App() { + useTrackRenders(); + // We can ignore the return result here since we are testing the mechanics + // of this hook to ensure it doesn't interfere with the updates from + // useReadQuery + const { subscribeToMore } = useQueryRefHandlers(queryRef); + + Profiler.mergeSnapshot({ subscribeToMore }); + + return ( + }> + + + ); + } + + renderWithClient(, { client, wrapper: Profiler }); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + const updateQuery = jest.fn< + ReturnType, + Parameters + >((_, { subscriptionData: { data } }) => { + return { greeting: data.greetingUpdated }; + }); + + const { snapshot } = Profiler.getCurrentRender(); + + snapshot.subscribeToMore!({ document: subscription, updateQuery }); + + wsLink.simulateResult({ + result: { + data: { + greetingUpdated: "Subscription hello", + }, + }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Subscription hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(updateQuery).toHaveBeenCalledTimes(1); + expect(updateQuery).toHaveBeenCalledWith( + { greeting: "Hello" }, + { + subscriptionData: { + data: { greetingUpdated: "Subscription hello" }, + }, + variables: {}, + } + ); +}); diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index 4b5a5668389..ba8dc1e71fd 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -17,7 +17,11 @@ import type { CacheKey, QueryRef } from "../internal/index.js"; import type { BackgroundQueryHookOptions, NoInfer } from "../types/types.js"; import { wrapHook } from "./internal/index.js"; import { useWatchQueryOptions } from "./useSuspenseQuery.js"; -import type { FetchMoreFunction, RefetchFunction } from "./useSuspenseQuery.js"; +import type { + FetchMoreFunction, + RefetchFunction, + SubscribeToMoreFunction, +} from "./useSuspenseQuery.js"; import { canonicalStringify } from "../../cache/index.js"; import type { DeepPartial } from "../../utilities/index.js"; import type { SkipToken } from "./constants.js"; @@ -26,7 +30,11 @@ export type UseBackgroundQueryResult< TData = unknown, TVariables extends OperationVariables = OperationVariables, > = { + /** {@inheritDoc @apollo/client!ObservableQuery#subscribeToMore:member(1)} */ + subscribeToMore: SubscribeToMoreFunction; + /** {@inheritDoc @apollo/client!ObservableQuery#fetchMore:member(1)} */ fetchMore: FetchMoreFunction; + /** {@inheritDoc @apollo/client!ObservableQuery#refetch:member(1)} */ refetch: RefetchFunction; }; @@ -281,6 +289,10 @@ function _useBackgroundQuery< return [ didFetchResult.current ? wrappedQueryRef : void 0, - { fetchMore, refetch }, + { + fetchMore, + refetch, + subscribeToMore: queryRef.observable.subscribeToMore, + }, ]; } diff --git a/src/react/hooks/useLoadableQuery.ts b/src/react/hooks/useLoadableQuery.ts index 15d1e2a7e56..b9aa70d11e2 100644 --- a/src/react/hooks/useLoadableQuery.ts +++ b/src/react/hooks/useLoadableQuery.ts @@ -18,7 +18,11 @@ import type { CacheKey, QueryRef } from "../internal/index.js"; import type { LoadableQueryHookOptions } from "../types/types.js"; import { __use, useRenderGuard } from "./internal/index.js"; import { useWatchQueryOptions } from "./useSuspenseQuery.js"; -import type { FetchMoreFunction, RefetchFunction } from "./useSuspenseQuery.js"; +import type { + FetchMoreFunction, + RefetchFunction, + SubscribeToMoreFunction, +} from "./useSuspenseQuery.js"; import { canonicalStringify } from "../../cache/index.js"; import type { DeepPartial, @@ -49,6 +53,8 @@ export type UseLoadableQueryResult< fetchMore: FetchMoreFunction; /** {@inheritDoc @apollo/client!QueryResultDocumentation#refetch:member} */ refetch: RefetchFunction; + /** {@inheritDoc @apollo/client!ObservableQuery#subscribeToMore:member(1)} */ + subscribeToMore: SubscribeToMoreFunction; /** * A function that resets the `queryRef` back to `null`. */ @@ -255,9 +261,22 @@ export function useLoadableQuery< ] ); + const subscribeToMore: SubscribeToMoreFunction = + React.useCallback( + (options) => { + invariant( + internalQueryRef, + "The query has not been loaded. Please load the query." + ); + + return internalQueryRef.observable.subscribeToMore(options); + }, + [internalQueryRef] + ); + const reset: ResetFunction = React.useCallback(() => { setQueryRef(null); }, []); - return [loadQuery, queryRef, { fetchMore, refetch, reset }]; + return [loadQuery, queryRef, { fetchMore, refetch, reset, subscribeToMore }]; } diff --git a/src/react/hooks/useQueryRefHandlers.ts b/src/react/hooks/useQueryRefHandlers.ts index 95036eafcf3..a621d579691 100644 --- a/src/react/hooks/useQueryRefHandlers.ts +++ b/src/react/hooks/useQueryRefHandlers.ts @@ -8,7 +8,11 @@ import { } from "../internal/index.js"; import type { QueryRef } from "../internal/index.js"; import type { OperationVariables } from "../../core/types.js"; -import type { RefetchFunction, FetchMoreFunction } from "./useSuspenseQuery.js"; +import type { + RefetchFunction, + FetchMoreFunction, + SubscribeToMoreFunction, +} from "./useSuspenseQuery.js"; import type { FetchMoreQueryOptions } from "../../core/watchQueryOptions.js"; import { useApolloClient } from "./useApolloClient.js"; import { wrapHook } from "./internal/index.js"; @@ -21,6 +25,8 @@ export interface UseQueryRefHandlersResult< refetch: RefetchFunction; /** {@inheritDoc @apollo/client!ObservableQuery#fetchMore:member(1)} */ fetchMore: FetchMoreFunction; + /** {@inheritDoc @apollo/client!ObservableQuery#subscribeToMore:member(1)} */ + subscribeToMore: SubscribeToMoreFunction; } /** @@ -112,5 +118,9 @@ function _useQueryRefHandlers< [internalQueryRef] ); - return { refetch, fetchMore }; + return { + refetch, + fetchMore, + subscribeToMore: internalQueryRef.observable.subscribeToMore, + }; } diff --git a/src/react/hooks/useSuspenseQuery.ts b/src/react/hooks/useSuspenseQuery.ts index fe438ab6240..e3395390a6b 100644 --- a/src/react/hooks/useSuspenseQuery.ts +++ b/src/react/hooks/useSuspenseQuery.ts @@ -274,13 +274,7 @@ function _useSuspenseQuery< [queryRef] ); - const subscribeToMore: SubscribeToMoreFunction< - TData | undefined, - TVariables - > = React.useCallback( - (options) => queryRef.observable.subscribeToMore(options), - [queryRef] - ); + const subscribeToMore = queryRef.observable.subscribeToMore; return React.useMemo< UseSuspenseQueryResult