From c80ba36e3f12c95e004fc74a3bbf22e1c63e387f Mon Sep 17 00:00:00 2001 From: Aditya Kumawat Date: Thu, 7 Dec 2023 00:40:20 +0530 Subject: [PATCH] Add `skipPollAttempt` option to control polling refetch behavior (#11397) Co-authored-by: Aditya Kumawat --- .api-reports/api-report-core.md | 3 +- .api-reports/api-report-react.md | 3 +- .api-reports/api-report-react_components.md | 3 +- .api-reports/api-report-react_context.md | 3 +- .api-reports/api-report-react_hoc.md | 3 +- .api-reports/api-report-react_hooks.md | 3 +- .api-reports/api-report-react_ssr.md | 3 +- .api-reports/api-report-testing.md | 3 +- .api-reports/api-report-testing_core.md | 3 +- .api-reports/api-report-utilities.md | 3 +- .api-reports/api-report.md | 3 +- .changeset/swift-zoos-collect.md | 19 ++ .size-limits.json | 4 +- src/core/ObservableQuery.ts | 5 +- src/core/watchQueryOptions.ts | 7 + src/react/hooks/__tests__/useQuery.test.tsx | 194 ++++++++++++++++++++ 16 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 .changeset/swift-zoos-collect.md diff --git a/.api-reports/api-report-core.md b/.api-reports/api-report-core.md index 8129823a675..e16b21db97b 100644 --- a/.api-reports/api-report-core.md +++ b/.api-reports/api-report-core.md @@ -2069,6 +2069,7 @@ export interface WatchQueryOptions; refetchWritePolicy?: RefetchWritePolicy; returnPartialData?: boolean; + skipPollAttempt?: () => boolean; variables?: TVariables; } @@ -2115,7 +2116,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:395:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:260: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 // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-react.md b/.api-reports/api-report-react.md index 268f955f1bf..7acf9ba2c5a 100644 --- a/.api-reports/api-report-react.md +++ b/.api-reports/api-report-react.md @@ -2235,6 +2235,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -2256,7 +2257,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1685,7 +1686,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1581,7 +1582,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1626,7 +1627,7 @@ export function withSubscription boolean; variables?: TVariables; } @@ -2147,7 +2148,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1567,7 +1568,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1629,7 +1630,7 @@ export function withWarningSpy(it: (...args: TArgs // src/core/types.ts:156:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:201:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:260:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-testing_core.md b/.api-reports/api-report-testing_core.md index a306aeb99fb..ad2f42b7825 100644 --- a/.api-reports/api-report-testing_core.md +++ b/.api-reports/api-report-testing_core.md @@ -1556,6 +1556,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -1586,7 +1587,7 @@ export function withWarningSpy(it: (...args: TArgs // src/core/types.ts:156:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:201:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:260:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-utilities.md b/.api-reports/api-report-utilities.md index 2c4231f2096..9ccfda99640 100644 --- a/.api-reports/api-report-utilities.md +++ b/.api-reports/api-report-utilities.md @@ -2413,6 +2413,7 @@ interface WatchQueryOptions boolean; variables?: TVariables; } @@ -2469,7 +2470,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/types.ts:156:3 - (ae-forgotten-export) The symbol "NetworkStatus" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:201:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:260:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/utilities/graphql/storeUtils.ts:226:12 - (ae-forgotten-export) The symbol "storeKeyNameStringify" needs to be exported by the entry point index.d.ts // src/utilities/policies/pagination.ts:76:3 - (ae-forgotten-export) The symbol "TRelayEdge" needs to be exported by the entry point index.d.ts // src/utilities/policies/pagination.ts:77:3 - (ae-forgotten-export) The symbol "TRelayPageInfo" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.md b/.api-reports/api-report.md index 081d7af9dc9..5a22a0eefdd 100644 --- a/.api-reports/api-report.md +++ b/.api-reports/api-report.md @@ -2853,6 +2853,7 @@ export interface WatchQueryOptions; refetchWritePolicy?: RefetchWritePolicy; returnPartialData?: boolean; + skipPollAttempt?: () => boolean; variables?: TVariables; } @@ -2899,7 +2900,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/QueryManager.ts:120:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:154:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:395:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts -// src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts +// src/core/watchQueryOptions.ts:260: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:30:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts // src/react/hooks/useBackgroundQuery.ts:31:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts diff --git a/.changeset/swift-zoos-collect.md b/.changeset/swift-zoos-collect.md new file mode 100644 index 00000000000..b3e988b8f0a --- /dev/null +++ b/.changeset/swift-zoos-collect.md @@ -0,0 +1,19 @@ +--- +"@apollo/client": minor +--- + +Adds a new `skipPollAttempt` callback function that's called whenever a refetch attempt occurs while polling. If the function returns `true`, the refetch is skipped and not reattempted until the next poll interval. This will solve the frequent use-case of disabling polling when the window is inactive. + +```ts +useQuery(QUERY, { + pollInterval: 1000, + skipPollAttempt: () => document.hidden // or !document.hasFocus() +}); +// or define it globally +new ApolloClient({ + defaultOptions: { + watchQuery: { + skipPollAttempt: () => document.hidden // or !document.hasFocus() + } + } +}) diff --git a/.size-limits.json b/.size-limits.json index f9e9c6f50a8..e23614a183f 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 38546, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32318 + "dist/apollo-client.min.cjs": 38576, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32352 } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index 8516678dfc8..4822807e565 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -776,7 +776,10 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const maybeFetch = () => { if (this.pollingInfo) { - if (!isNetworkRequestInFlight(this.queryInfo.networkStatus)) { + if ( + !isNetworkRequestInFlight(this.queryInfo.networkStatus) && + !this.options.skipPollAttempt?.() + ) { this.reobserve( { // Most fetchPolicy options don't make sense to use in a polling context, as diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index b74659b4a99..7c49d861097 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -176,6 +176,13 @@ export interface WatchQueryOptions< /** {@inheritDoc @apollo/client!QueryOptions#canonizeResults:member} */ canonizeResults?: boolean; + + /** + * A callback function that's called whenever a refetch attempt occurs + * while polling. If the function returns `true`, the refetch is + * skipped and not reattempted until the next poll interval. + */ + skipPollAttempt?: () => boolean; } export interface NextFetchPolicyContext< diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 0c6a56edcdc..2e6f1e3a125 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2092,6 +2092,200 @@ describe("useQuery Hook", () => { unmount(); result.current.stopPolling(); }); + + describe("should prevent fetches when `skipPollAttempt` returns `false`", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it("when defined as a global default option", async () => { + const skipPollAttempt = jest.fn().mockImplementation(() => false); + + const query = gql` + { + hello + } + `; + const link = mockSingleLink( + { + request: { query }, + result: { data: { hello: "world 1" } }, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + } + ); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + skipPollAttempt, + }, + }, + }); + + const wrapper = ({ children }: any) => ( + {children} + ); + + const { result } = renderHook( + () => useQuery(query, { pollInterval: 10 }), + { wrapper } + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 1" }); + }, + { interval: 1 } + ); + + expect(result.current.loading).toBe(false); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 2" }); + }, + { interval: 1 } + ); + + skipPollAttempt.mockImplementation(() => true); + expect(result.current.loading).toBe(false); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + skipPollAttempt.mockImplementation(() => false); + expect(result.current.loading).toBe(false); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 3" }); + }, + { interval: 1 } + ); + }); + + it("when defined for a single query", async () => { + const skipPollAttempt = jest.fn().mockImplementation(() => false); + + const query = gql` + { + hello + } + `; + const mocks = [ + { + request: { query }, + result: { data: { hello: "world 1" } }, + }, + { + request: { query }, + result: { data: { hello: "world 2" } }, + }, + { + request: { query }, + result: { data: { hello: "world 3" } }, + }, + ]; + + const cache = new InMemoryCache(); + const wrapper = ({ children }: any) => ( + + {children} + + ); + + const { result } = renderHook( + () => + useQuery(query, { + pollInterval: 10, + skipPollAttempt, + }), + { wrapper } + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 1" }); + }, + { interval: 1 } + ); + + expect(result.current.loading).toBe(false); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 2" }); + }, + { interval: 1 } + ); + + skipPollAttempt.mockImplementation(() => true); + expect(result.current.loading).toBe(false); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + await jest.advanceTimersByTime(12); + await waitFor( + () => expect(result.current.data).toEqual({ hello: "world 2" }), + { interval: 1 } + ); + + skipPollAttempt.mockImplementation(() => false); + expect(result.current.loading).toBe(false); + + await waitFor( + () => { + expect(result.current.data).toEqual({ hello: "world 3" }); + }, + { interval: 1 } + ); + }); + }); }); describe("Error handling", () => {