From 10c8755bf437da4f4ed2806eeb065713ec4f9d54 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Fri, 6 Dec 2024 17:52:30 +0100 Subject: [PATCH 01/17] docs: adjust screenshot size (#12178) --- docs/source/development-testing/developer-tooling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/development-testing/developer-tooling.md b/docs/source/development-testing/developer-tooling.md index 2ef169783f2..913da807ee8 100644 --- a/docs/source/development-testing/developer-tooling.md +++ b/docs/source/development-testing/developer-tooling.md @@ -29,7 +29,7 @@ The Apollo Client Devtools appear as an "Apollo" tab in your web browser's Inspe - **Mutation inspector:** View active mutations and their variables, and re-run individual mutations. - **Cache inspector:** Visualize the Apollo Client cache and search it by field name and/or value. -![Apollo Client Devtools](../assets/devtools/apollo-client-devtools/ac-browser-devtools-3.png) +Apollo Client Devtools ### Installation From e022f72b3911b19f5b06f14ef665e503175c2da0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 11 Dec 2024 09:19:34 -0700 Subject: [PATCH 02/17] Update ROADMAP.md --- ROADMAP.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9840dd9dfd1..2d96bae7d14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # đź”® Apollo Client Ecosystem Roadmap -**Last updated: 2024-11-04** +**Last updated: 2024-12-11** For up to date release notes, refer to the project's [Changelog](https://github.com/apollographql/apollo-client/blob/main/CHANGELOG.md). @@ -17,42 +17,41 @@ For up to date release notes, refer to the project's [Changelog](https://github. ### Apollo Client -#### [3.12.0](https://github.com/apollographql/apollo-client/milestone/42) - November 18, 2024 -_Release candidate - November 11, 2024_ +#### [3.13.0](https://github.com/apollographql/apollo-client/milestone/42) - January 13, 2024 +_Release candidate - January 7th_ -- Data masking +- `useSuspenseFragment` #### Upcoming features -- Leaner client (under alternate entry point) -- Better types for `useQuery`/`useMutation`/`useSubscription` -- Introduce `useSuspenseFragment` that will suspend when the data is not yet loaded (experimental) +- Deprecations and preparations for 4.0 #### 4.0 -- `Release 4.0` will be our next major release of the Client and is still in early planning. See Github [4.0 Milestone](https://github.com/apollographql/apollo-client/milestone/31) for more details. +- `Release 4.0` will be our next major release of the Client and is still in planning. See Github [4.0 Milestone](https://github.com/apollographql/apollo-client/milestone/31) for more details. ### GraphQL Testing Library - New documentation - Subscription support (waiting for MSW WebSocket support to land) +_These changes will take longer than anticipated due to prioritization on Apollo Client 4.0_ + ### VSCode Extension -- Bug fixes and long-requested features -- Apollo Client Devtools integration +_No outstanding work_ ### GraphQL Tag -- Started 3.0 milestone planning +- `Release 3.0` will be our next major release of `graphql-tag` and is still in planning. See Github [3.0 Milestone](https://github.com/apollographql/graphql-tag/milestone/3) for more details. ### Apollo Client DevTools -- Ongoing work with fixing error messages shown in devtools -- Add a memory panel to monitor Apollo Client devtools internal caches -- Connectors debugger +_These changes will take longer than anticipated due to prioritization on Apollo Client 4.0_ -### Apollo Client NextJS +### Apollo Client React Framework Integrations - New/more robust documentation - Support for `@defer` in RSC +- Support for Apollo Client Streaming in TanStack Router +- Support for Apollo Client Streaming in React Router 7 From db010598ebe07267c0b7a8c9de3f353abe1a2ddd Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 11 Dec 2024 17:52:53 +0100 Subject: [PATCH 03/17] add documentation for vscode devtools (#12205) --- .semgrepignore | 1 + docs/source/assets/devtools/vscode-panel.png | Bin 0 -> 55986 bytes .../source/assets/devtools/vscode-setting.png | Bin 0 -> 40225 bytes ...loper-tooling.md => developer-tooling.mdx} | 35 ++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 docs/source/assets/devtools/vscode-panel.png create mode 100644 docs/source/assets/devtools/vscode-setting.png rename docs/source/development-testing/{developer-tooling.md => developer-tooling.mdx} (60%) diff --git a/.semgrepignore b/.semgrepignore index 3031b723ab1..5b3c586139a 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -11,3 +11,4 @@ dist/ # custom paths __tests__/ ./docs/source/data/subscriptions.mdx +./docs/source/development-testing/developer-tooling.mdx \ No newline at end of file diff --git a/docs/source/assets/devtools/vscode-panel.png b/docs/source/assets/devtools/vscode-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..d330d1e74a34c9cefa49162eff0c341dc52dda79 GIT binary patch literal 55986 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 M_(GxRiRs(_0aWD_0{{R3 literal 0 HcmV?d00001 diff --git a/docs/source/assets/devtools/vscode-setting.png b/docs/source/assets/devtools/vscode-setting.png new file mode 100644 index 0000000000000000000000000000000000000000..1d2db0fc27273f8b88de9b6b83c9a5243b27f577 GIT binary patch literal 40225 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 literal 0 HcmV?d00001 diff --git a/docs/source/development-testing/developer-tooling.md b/docs/source/development-testing/developer-tooling.mdx similarity index 60% rename from docs/source/development-testing/developer-tooling.md rename to docs/source/development-testing/developer-tooling.mdx index 913da807ee8..11e30ddfe8a 100644 --- a/docs/source/development-testing/developer-tooling.md +++ b/docs/source/development-testing/developer-tooling.mdx @@ -40,3 +40,38 @@ You can install the extension via the webstores for [Chrome](https://chrome.goog While your app is in dev mode, the Apollo Client Devtools will appear as an "Apollo" tab in your web browser inspector. To enable the devtools in your app in production, pass `connectToDevTools: true` to the `ApolloClient` constructor in your app. Pass `connectToDevTools: false` if want to manually disable this functionality. Find more information about contributing and debugging on the [Apollo Client Devtools GitHub page](https://github.com/apollographql/apollo-client-devtools). + +## Apollo Client Devtools in VS Code + +The Apollo VSCode extension ships with an instance of the Apollo Client Devtools. +You can use it to remotely debug your client, which makes it possible to also debug React Native and node applications. + +The following sections walk through how to install and integrate with the extension. + + +This feature is currently released as "experimental" - please try it out and give us feedback in our [GitHub issues](https://github.com/apollographql/vscode-graphql/issues)! + + +* Install the Apollo VS Code extension: [start installation](vscode:extension/apollographql.vscode-apollo) | [marketplace page](https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo) +* Set the "Apollographql > Dev Tools: Show Panel" setting to "detect" or "always" in the VS code settings dialog. +A screenshot of the VS Code settings dialog focusing on the 'Show Panel' option +* In your code base, install the `@apollo/client-devtools-vscode` package: +```sh +npm install @apollo/client-devtools-vscode +``` +* After initializing your `ApolloClient` instance, call `registerClient` with your client instance. +```js +import { registerClient } from "@apollo/client-devtools-vscode"; + +const client = new ApolloClient({ /* ... */ }); + +// we recommend wrapping this statement in a check for e.g. process.env.NODE_ENV === "development" +const devtoolsRegistration = registerClient( + client, + // the default port of the VSCode DevTools is 7095 + "ws://localhost:7095", +); +``` +* Open the "Apollo Client DevTools" panel in VS Code. +* Start your application. It should automatically connect to the DevTools. +Apollo Client Devtools in a VS Code panel From 851deb06f42eb255b4839c2b88430f991943ae0f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 12 Dec 2024 06:03:02 -0700 Subject: [PATCH 04/17] Ensure `MaybeMasked` doesn't unwrap if it contains `any` (#12204) Co-authored-by: Lenz Weber-Tronic Co-authored-by: phryneas --- .api-reports/api-report-cache.api.md | 13 +- .api-reports/api-report-core.api.md | 13 +- .api-reports/api-report-masking.api.md | 13 +- .api-reports/api-report-react.api.md | 13 +- .../api-report-react_components.api.md | 13 +- .api-reports/api-report-react_context.api.md | 13 +- .api-reports/api-report-react_hoc.api.md | 13 +- .api-reports/api-report-react_hooks.api.md | 13 +- .api-reports/api-report-react_internal.api.md | 13 +- .api-reports/api-report-react_ssr.api.md | 13 +- .api-reports/api-report-testing.api.md | 13 +- .api-reports/api-report-testing_core.api.md | 13 +- .api-reports/api-report-utilities.api.md | 12 +- .api-reports/api-report.api.md | 13 +- .changeset/friendly-papayas-breathe.md | 5 + .changeset/happy-weeks-buy.md | 5 + .changeset/three-pandas-smash.md | 5 + src/masking/__benches__/types.bench.ts | 115 +++++++++++++++++- src/masking/internal/types.ts | 8 +- src/utilities/index.ts | 1 + src/utilities/types/RemoveIndexSignature.ts | 6 + 21 files changed, 279 insertions(+), 47 deletions(-) create mode 100644 .changeset/friendly-papayas-breathe.md create mode 100644 .changeset/happy-weeks-buy.md create mode 100644 .changeset/three-pandas-smash.md create mode 100644 src/utilities/types/RemoveIndexSignature.ts diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 72f792334a2..0e90e2b82fc 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -236,8 +236,11 @@ type CombineIntersection = Exclude>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export function createFragmentRegistry(...fragments: DocumentNode[]): FragmentRegistryAPI; @@ -757,7 +760,6 @@ export function makeReference(id: string): Reference; // @public (undocumented) export function makeVar(value: T): ReactiveVar; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1033,6 +1035,11 @@ export interface Reference { // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1109,7 +1116,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index cb31ddd4692..a5d7d74745e 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -479,8 +479,11 @@ type ConcastSourcesIterable = Iterable>; // @public (undocumented) export const concat: typeof ApolloLink.concat; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export const createHttpLink: (linkOptions?: HttpOptions) => ApolloLink; @@ -1401,7 +1404,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts // @@ -2170,6 +2172,11 @@ export type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2406,7 +2413,7 @@ export type Unmasked = true extends IsAny ? TData : TData extends // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-masking.api.md b/.api-reports/api-report-masking.api.md index 0d22ad2fe1b..12d24672ff5 100644 --- a/.api-reports/api-report-masking.api.md +++ b/.api-reports/api-report-masking.api.md @@ -220,8 +220,11 @@ type CombineIntersection = Exclude>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export interface DataMasking { @@ -429,7 +432,6 @@ export function maskFragment(data: TData, document: TypedDocume // @internal (undocumented) export function maskOperation(data: TData, document: DocumentNode | TypedDocumentNode, cache: ApolloCache): TData; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts // @@ -558,6 +560,11 @@ interface Reference { // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -608,7 +615,7 @@ export type Unmasked = true extends IsAny ? TData : TData extends // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 8373acf4521..8f302c82f85 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -577,8 +577,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export interface Context extends Record { @@ -1146,7 +1149,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1955,6 +1957,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2202,7 +2209,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_components.api.md b/.api-reports/api-report-react_components.api.md index c9ea4d9100a..739af65d3b8 100644 --- a/.api-reports/api-report-react_components.api.md +++ b/.api-reports/api-report-react_components.api.md @@ -523,8 +523,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) interface DataMasking { @@ -1009,7 +1012,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1737,6 +1739,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1935,7 +1942,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_context.api.md b/.api-reports/api-report-react_context.api.md index c9d716e731e..812f228974f 100644 --- a/.api-reports/api-report-react_context.api.md +++ b/.api-reports/api-report-react_context.api.md @@ -517,8 +517,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) interface DataMasking { @@ -1006,7 +1009,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1665,6 +1667,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1855,7 +1862,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_hoc.api.md b/.api-reports/api-report-react_hoc.api.md index 7579701495f..e05e2f61121 100644 --- a/.api-reports/api-report-react_hoc.api.md +++ b/.api-reports/api-report-react_hoc.api.md @@ -506,8 +506,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) interface DataMasking { @@ -1013,7 +1016,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1694,6 +1696,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1859,7 +1866,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index b639caed65d..da083a0abd7 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -546,8 +546,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) interface DataMasking { @@ -1095,7 +1098,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1814,6 +1816,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2025,7 +2032,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index f5587b3de51..dd6f0073fe5 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -525,8 +525,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // Warning: (ae-forgotten-export) The symbol "PreloadQueryFunction" needs to be exported by the entry point index.d.ts // @@ -1105,7 +1108,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1867,6 +1869,11 @@ interface RejectedPromise extends Promise { // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2077,7 +2084,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index cb40f4d1f28..b122eff81ce 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -486,8 +486,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) interface DataMasking { @@ -991,7 +994,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1650,6 +1652,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1840,7 +1847,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-testing.api.md b/.api-reports/api-report-testing.api.md index 00f01de37dd..c38f15e8bf7 100644 --- a/.api-reports/api-report-testing.api.md +++ b/.api-reports/api-report-testing.api.md @@ -476,8 +476,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @internal (undocumented) type CovariantUnaryFunction = { @@ -980,7 +983,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1715,6 +1717,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1891,7 +1898,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 1606498c68a..884f6b13152 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -475,8 +475,11 @@ class Concast extends Observable { // @public (undocumented) type ConcastSourcesIterable = Iterable>; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @internal (undocumented) type CovariantUnaryFunction = { @@ -979,7 +982,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -1672,6 +1674,11 @@ type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -1848,7 +1855,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 76b0f621592..5cb19b3c19e 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -606,8 +606,10 @@ export type ConcastSourcesIterable = Iterable>; // @public (undocumented) export function concatPagination(keyArgs?: KeyArgs): FieldPolicy; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export function createFragmentMap(fragments?: FragmentDefinitionNode[]): FragmentMap; @@ -1689,7 +1691,6 @@ type MaybeAsync = T | PromiseLike; // @public (undocumented) export function maybeDeepFreeze(obj: T): T; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DataMasking" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts @@ -2519,6 +2520,11 @@ export type RemoveFragmentSpreadConfig = RemoveNodeConfig; // @public (undocumented) export function removeFragmentSpreadFromDocument(config: RemoveFragmentSpreadConfig[], doc: DocumentNode): DocumentNode | null; +// @public (undocumented) +export type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2770,7 +2776,7 @@ type Unmasked = true extends IsAny ? TData : TData extends object // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 3a4bac0f7a6..e91fa9f983f 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -576,8 +576,11 @@ type ConcastSourcesIterable = Iterable>; // @public (undocumented) export const concat: typeof ApolloLink.concat; +// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -type ContainsFragmentsRefs = TData extends object ? " $fragmentRefs" extends keyof TData ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; // @public (undocumented) export const createHttpLink: (linkOptions?: HttpOptions) => ApolloLink; @@ -1582,7 +1585,6 @@ interface MaskOperationOptions { // @public (undocumented) type MaybeAsync = T | PromiseLike; -// Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveMaskedMarker" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ContainsFragmentsRefs" needs to be exported by the entry point index.d.ts // @@ -2552,6 +2554,11 @@ export type RefetchWritePolicy = "merge" | "overwrite"; // @public (undocumented) type RemoveFragmentName = T extends any ? Omit : T; +// @public (undocumented) +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + // @public (undocumented) type RemoveMaskedMarker = Omit; @@ -2871,7 +2878,7 @@ export type Unmasked = true extends IsAny ? TData : TData extends // @public (undocumented) type UnwrapFragmentRefs = true extends IsAny ? TData : TData extends any ? string extends keyof TData ? TData : keyof TData extends never ? TData : TData extends { " $fragmentRefs"?: infer FragmentRefs; -} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends Array ? Array> : TData extends object ? { +} ? UnwrapFragmentRefs | RemoveFragmentName[keyof NonNullable]>>>> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; } : TData : never; diff --git a/.changeset/friendly-papayas-breathe.md b/.changeset/friendly-papayas-breathe.md new file mode 100644 index 00000000000..7fb105b6f3c --- /dev/null +++ b/.changeset/friendly-papayas-breathe.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix `Unmasked` unwrapping tuple types into an array of their subtypes. diff --git a/.changeset/happy-weeks-buy.md b/.changeset/happy-weeks-buy.md new file mode 100644 index 00000000000..61d9f4eb21d --- /dev/null +++ b/.changeset/happy-weeks-buy.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure `MaybeMasked` does not try and unwrap types that contain index signatures. diff --git a/.changeset/three-pandas-smash.md b/.changeset/three-pandas-smash.md new file mode 100644 index 00000000000..97c1cc673ed --- /dev/null +++ b/.changeset/three-pandas-smash.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensure `MaybeMasked` does not try to unwrap the type as `Unmasked` if the type contains `any`. diff --git a/src/masking/__benches__/types.bench.ts b/src/masking/__benches__/types.bench.ts index 621fb29dfaf..c96231a96af 100644 --- a/src/masking/__benches__/types.bench.ts +++ b/src/masking/__benches__/types.bench.ts @@ -299,7 +299,7 @@ test("MaybeMasked handles odd types", (prefix) => { bench(prefix + "unknown instantiations", () => { attest>(); - }).types([52, "instantiations"]); + }).types([54, "instantiations"]); bench(prefix + "unknown functionality", () => { expectTypeOf>().toBeUnknown(); }); @@ -464,3 +464,116 @@ test("base type, multiple fragments on sub-types", (prefix) => { }>(); }); }); + +test("does not detect `$fragmentRefs` if type contains `any`", (prefix) => { + interface Source { + foo: { bar: any[] }; + " $fragmentName": "foo"; + } + + bench(prefix + "instantiations", () => { + return {} as MaybeMasked; + }).types([6, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as MaybeMasked; + + expectTypeOf(x).branded.toEqualTypeOf(); + }); +}); + +test("leaves tuples alone", (prefix) => { + interface Source { + coords: [long: number, lat: number]; + } + + bench(prefix + "instantiations", () => { + return {} as Unmasked; + }).types([5, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as Unmasked; + + expectTypeOf(x).branded.toEqualTypeOf<{ + coords: [long: number, lat: number]; + }>(); + }); +}); + +test("does not detect `$fragmentRefs` if type is a record type", (prefix) => { + interface MetadataItem { + foo: string; + } + + interface Source { + metadata: Record; + " $fragmentName": "Source"; + } + + bench(prefix + "instantiations", () => { + return {} as MaybeMasked; + }).types([6, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as MaybeMasked; + + expectTypeOf(x).branded.toEqualTypeOf(); + }); +}); + +test("does not detect `$fragmentRefs` on types with index signatures", (prefix) => { + interface Source { + foo: string; + " $fragmentName": "Source"; + [key: string]: string; + } + + bench(prefix + "instantiations", () => { + return {} as MaybeMasked; + }).types([6, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as MaybeMasked; + + expectTypeOf(x).branded.toEqualTypeOf(); + }); +}); + +test("detects `$fragmentRefs` on types with index signatures", (prefix) => { + type Source = { + __typename: "Foo"; + id: number; + metadata: Record; + structuredMetadata: StructuredMetadata; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { + FooFragment: FooFragment; + }; + }; + + interface StructuredMetadata { + bar: number; + [index: string]: number; + } + + type FooFragment = { + __typename: "Foo"; + foo: string; + } & { " $fragmentName"?: "FooFragment" }; + + bench(prefix + "instantiations", () => { + return {} as MaybeMasked; + }).types([6, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as MaybeMasked; + + expectTypeOf(x).branded.toEqualTypeOf<{ + __typename: "Foo"; + id: number; + metadata: Record; + foo: string; + structuredMetadata: StructuredMetadata; + }>(); + }); +}); diff --git a/src/masking/internal/types.ts b/src/masking/internal/types.ts index ead6b64abdf..4c769de426d 100644 --- a/src/masking/internal/types.ts +++ b/src/masking/internal/types.ts @@ -1,4 +1,4 @@ -import type { Prettify } from "../../utilities/index.js"; +import type { Prettify, RemoveIndexSignature } from "../../utilities/index.js"; export type IsAny = 0 extends 1 & T ? true : false; @@ -20,7 +20,6 @@ export type UnwrapFragmentRefs = > > > - : TData extends Array ? Array> : TData extends object ? { [K in keyof TData]: UnwrapFragmentRefs; @@ -184,8 +183,9 @@ export type RemoveFragmentName = T extends any ? Omit : T; export type ContainsFragmentsRefs = - TData extends object ? - " $fragmentRefs" extends keyof TData ? + true extends IsAny ? false + : TData extends object ? + " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 300cafb0d56..f4e7705deb6 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -139,6 +139,7 @@ export type { OnlyRequiredProperties } from "./types/OnlyRequiredProperties.js"; export type { Prettify } from "./types/Prettify.js"; export type { UnionToIntersection } from "./types/UnionToIntersection.js"; export type { NoInfer } from "./types/NoInfer.js"; +export type { RemoveIndexSignature } from "./types/RemoveIndexSignature.js"; export { AutoCleanedStrongCache, diff --git a/src/utilities/types/RemoveIndexSignature.ts b/src/utilities/types/RemoveIndexSignature.ts new file mode 100644 index 00000000000..4aad90687d9 --- /dev/null +++ b/src/utilities/types/RemoveIndexSignature.ts @@ -0,0 +1,6 @@ +export type RemoveIndexSignature = { + [K in keyof T as string extends K ? never + : number extends K ? never + : symbol extends K ? never + : K]: T[K]; +}; From 8bfee88102dd071ea5836f7267f30ca082671b2b Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 12 Dec 2024 16:16:20 +0100 Subject: [PATCH 05/17] prevent infinite recursion of ContainsFragmentsRefs type (#12214) --- .api-reports/api-report-cache.api.md | 6 +++++- .api-reports/api-report-core.api.md | 6 +++++- .api-reports/api-report-masking.api.md | 6 +++++- .api-reports/api-report-react.api.md | 6 +++++- .api-reports/api-report-react_components.api.md | 6 +++++- .api-reports/api-report-react_context.api.md | 6 +++++- .api-reports/api-report-react_hoc.api.md | 6 +++++- .api-reports/api-report-react_hooks.api.md | 6 +++++- .api-reports/api-report-react_internal.api.md | 6 +++++- .api-reports/api-report-react_ssr.api.md | 6 +++++- .api-reports/api-report-testing.api.md | 6 +++++- .api-reports/api-report-testing_core.api.md | 6 +++++- .api-reports/api-report-utilities.api.md | 6 +++++- .api-reports/api-report.api.md | 6 +++++- .changeset/chilly-icons-shave.md | 5 +++++ src/masking/__benches__/types.bench.ts | 15 +++++++++++++++ src/masking/internal/types.ts | 17 ++++++++++------- 17 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 .changeset/chilly-icons-shave.md diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 0e90e2b82fc..908176a26cf 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -237,10 +237,11 @@ type CombineIntersection = Exclude>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export function createFragmentRegistry(...fragments: DocumentNode[]): FragmentRegistryAPI; @@ -465,6 +466,9 @@ export namespace EntityStore { } } +// @public (undocumented) +type Exact = (x: T) => T; + // @public type ExtractByMatchingTypeNames = Iterable>; export const concat: typeof ApolloLink.concat; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export const createHttpLink: (linkOptions?: HttpOptions) => ApolloLink; @@ -790,6 +791,9 @@ namespace EntityStore { // @public export type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // @public (undocumented) export const execute: typeof ApolloLink.execute; diff --git a/.api-reports/api-report-masking.api.md b/.api-reports/api-report-masking.api.md index 12d24672ff5..fffd2efb2e1 100644 --- a/.api-reports/api-report-masking.api.md +++ b/.api-reports/api-report-masking.api.md @@ -221,10 +221,11 @@ type CombineIntersection = Exclude>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export interface DataMasking { @@ -360,6 +361,9 @@ export const disableWarningsSlot: { // @public (undocumented) type DistributedRequiredExclude = T extends any ? Required extends Required ? Required extends Required ? never : T : T : T; +// @public (undocumented) +type Exact = (x: T) => T; + // @public type ExtractByMatchingTypeNames extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export interface Context extends Record { @@ -779,6 +780,9 @@ export { DocumentType_2 as DocumentType } // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_components.api.md b/.api-reports/api-report-react_components.api.md index 739af65d3b8..051470cb154 100644 --- a/.api-reports/api-report-react_components.api.md +++ b/.api-reports/api-report-react_components.api.md @@ -524,10 +524,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) interface DataMasking { @@ -711,6 +712,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_context.api.md b/.api-reports/api-report-react_context.api.md index 812f228974f..703cfb4825d 100644 --- a/.api-reports/api-report-react_context.api.md +++ b/.api-reports/api-report-react_context.api.md @@ -518,10 +518,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) interface DataMasking { @@ -705,6 +706,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_hoc.api.md b/.api-reports/api-report-react_hoc.api.md index e05e2f61121..3f867350519 100644 --- a/.api-reports/api-report-react_hoc.api.md +++ b/.api-reports/api-report-react_hoc.api.md @@ -507,10 +507,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) interface DataMasking { @@ -703,6 +704,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index da083a0abd7..b252b2a672f 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -547,10 +547,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) interface DataMasking { @@ -734,6 +735,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index dd6f0073fe5..3e43a1dafbb 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -526,10 +526,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // Warning: (ae-forgotten-export) The symbol "PreloadQueryFunction" needs to be exported by the entry point index.d.ts // @@ -718,6 +719,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index b122eff81ce..d0560e817dd 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -487,10 +487,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) interface DataMasking { @@ -674,6 +675,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-testing.api.md b/.api-reports/api-report-testing.api.md index c38f15e8bf7..0c7b64d8eee 100644 --- a/.api-reports/api-report-testing.api.md +++ b/.api-reports/api-report-testing.api.md @@ -477,10 +477,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @internal (undocumented) type CovariantUnaryFunction = { @@ -675,6 +676,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 884f6b13152..d46a9f55d1f 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -476,10 +476,11 @@ class Concast extends Observable { type ConcastSourcesIterable = Iterable>; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @internal (undocumented) type CovariantUnaryFunction = { @@ -674,6 +675,9 @@ interface DocumentTransformOptions { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 5cb19b3c19e..a09ee2ed138 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -607,9 +607,10 @@ export type ConcastSourcesIterable = Iterable>; export function concatPagination(keyArgs?: KeyArgs): FieldPolicy; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export function createFragmentMap(fragments?: FragmentDefinitionNode[]): FragmentMap; @@ -981,6 +982,9 @@ namespace EntityStore { // @public type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResultBase" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index e91fa9f983f..850d6e603b9 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -577,10 +577,11 @@ type ConcastSourcesIterable = Iterable>; export const concat: typeof ApolloLink.concat; // Warning: (ae-forgotten-export) The symbol "IsAny" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Exact" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type ContainsFragmentsRefs = true extends IsAny ? false : TData extends object ? " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs : false; +type ContainsFragmentsRefs = true extends (IsAny) ? false : TData extends object ? Exact extends Seen ? false : " $fragmentRefs" extends keyof RemoveIndexSignature ? true : ContainsFragmentsRefs> : false; // @public (undocumented) export const createHttpLink: (linkOptions?: HttpOptions) => ApolloLink; @@ -903,6 +904,9 @@ namespace EntityStore { // @public export type ErrorPolicy = "none" | "ignore" | "all"; +// @public (undocumented) +type Exact = (x: T) => T; + // @public (undocumented) export const execute: typeof ApolloLink.execute; diff --git a/.changeset/chilly-icons-shave.md b/.changeset/chilly-icons-shave.md new file mode 100644 index 00000000000..d550c5a48fe --- /dev/null +++ b/.changeset/chilly-icons-shave.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Data masking: prevent infinite recursion of `ContainsFragmentsRefs` type diff --git a/src/masking/__benches__/types.bench.ts b/src/masking/__benches__/types.bench.ts index c96231a96af..0ae97b7edc4 100644 --- a/src/masking/__benches__/types.bench.ts +++ b/src/masking/__benches__/types.bench.ts @@ -577,3 +577,18 @@ test("detects `$fragmentRefs` on types with index signatures", (prefix) => { }>(); }); }); + +test("recursive types: no error 'Type instantiation is excessively deep and possibly infinite.'", (prefix) => { + // this type is self-recursive + type Source = import("graphql").IntrospectionQuery; + + bench(prefix + "instantiations", () => { + return {} as MaybeMasked; + }).types([6, "instantiations"]); + + bench(prefix + "functionality", () => { + const x = {} as MaybeMasked; + + expectTypeOf(x).branded.toEqualTypeOf(); + }); +}); diff --git a/src/masking/internal/types.ts b/src/masking/internal/types.ts index 4c769de426d..828734a8934 100644 --- a/src/masking/internal/types.ts +++ b/src/masking/internal/types.ts @@ -182,10 +182,13 @@ export type RemoveMaskedMarker = Omit; export type RemoveFragmentName = T extends any ? Omit : T; -export type ContainsFragmentsRefs = - true extends IsAny ? false - : TData extends object ? - " $fragmentRefs" extends keyof RemoveIndexSignature ? - true - : ContainsFragmentsRefs - : false; +type Exact = (x: T) => T; +export type ContainsFragmentsRefs = true extends ( + IsAny +) ? + false +: TData extends object ? + Exact extends Seen ? false + : " $fragmentRefs" extends keyof RemoveIndexSignature ? true + : ContainsFragmentsRefs> +: false; From e2b15c4b72d016b27293253b5a3fa6c319c1fb93 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 08:26:58 -0700 Subject: [PATCH 06/17] Version Packages (#12213) Co-authored-by: github-actions[bot] --- .changeset/chilly-icons-shave.md | 5 ----- .changeset/friendly-papayas-breathe.md | 5 ----- .changeset/happy-weeks-buy.md | 5 ----- .changeset/three-pandas-smash.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 7 files changed, 15 insertions(+), 23 deletions(-) delete mode 100644 .changeset/chilly-icons-shave.md delete mode 100644 .changeset/friendly-papayas-breathe.md delete mode 100644 .changeset/happy-weeks-buy.md delete mode 100644 .changeset/three-pandas-smash.md diff --git a/.changeset/chilly-icons-shave.md b/.changeset/chilly-icons-shave.md deleted file mode 100644 index d550c5a48fe..00000000000 --- a/.changeset/chilly-icons-shave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apollo/client": patch ---- - -Data masking: prevent infinite recursion of `ContainsFragmentsRefs` type diff --git a/.changeset/friendly-papayas-breathe.md b/.changeset/friendly-papayas-breathe.md deleted file mode 100644 index 7fb105b6f3c..00000000000 --- a/.changeset/friendly-papayas-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apollo/client": patch ---- - -Fix `Unmasked` unwrapping tuple types into an array of their subtypes. diff --git a/.changeset/happy-weeks-buy.md b/.changeset/happy-weeks-buy.md deleted file mode 100644 index 61d9f4eb21d..00000000000 --- a/.changeset/happy-weeks-buy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apollo/client": patch ---- - -Ensure `MaybeMasked` does not try and unwrap types that contain index signatures. diff --git a/.changeset/three-pandas-smash.md b/.changeset/three-pandas-smash.md deleted file mode 100644 index 97c1cc673ed..00000000000 --- a/.changeset/three-pandas-smash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apollo/client": patch ---- - -Ensure `MaybeMasked` does not try to unwrap the type as `Unmasked` if the type contains `any`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 173e739fd1e..76411547510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # @apollo/client +## 3.12.3 + +### Patch Changes + +- [#12214](https://github.com/apollographql/apollo-client/pull/12214) [`8bfee88`](https://github.com/apollographql/apollo-client/commit/8bfee88102dd071ea5836f7267f30ca082671b2b) Thanks [@phryneas](https://github.com/phryneas)! - Data masking: prevent infinite recursion of `ContainsFragmentsRefs` type + +- [#12204](https://github.com/apollographql/apollo-client/pull/12204) [`851deb0`](https://github.com/apollographql/apollo-client/commit/851deb06f42eb255b4839c2b88430f991943ae0f) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Fix `Unmasked` unwrapping tuple types into an array of their subtypes. + +- [#12204](https://github.com/apollographql/apollo-client/pull/12204) [`851deb0`](https://github.com/apollographql/apollo-client/commit/851deb06f42eb255b4839c2b88430f991943ae0f) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure `MaybeMasked` does not try and unwrap types that contain index signatures. + +- [#12204](https://github.com/apollographql/apollo-client/pull/12204) [`851deb0`](https://github.com/apollographql/apollo-client/commit/851deb06f42eb255b4839c2b88430f991943ae0f) Thanks [@jerelmiller](https://github.com/jerelmiller)! - Ensure `MaybeMasked` does not try to unwrap the type as `Unmasked` if the type contains `any`. + ## 3.12.2 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index f568baca25f..c52adc1b1f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apollo/client", - "version": "3.12.2", + "version": "3.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apollo/client", - "version": "3.12.2", + "version": "3.12.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e509e80e8c6..1e58fef8c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.12.2", + "version": "3.12.3", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [ From 0809b888fc0f07a8b17e53890569c2573814bf16 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 12 Dec 2024 17:59:17 +0100 Subject: [PATCH 07/17] update import name for devtools vscode package (#12215) --- docs/source/development-testing/developer-tooling.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/development-testing/developer-tooling.mdx b/docs/source/development-testing/developer-tooling.mdx index 11e30ddfe8a..7a43af36713 100644 --- a/docs/source/development-testing/developer-tooling.mdx +++ b/docs/source/development-testing/developer-tooling.mdx @@ -59,14 +59,14 @@ This feature is currently released as "experimental" - please try it out and giv ```sh npm install @apollo/client-devtools-vscode ``` -* After initializing your `ApolloClient` instance, call `registerClient` with your client instance. +* After initializing your `ApolloClient` instance, call `connectApolloClientToVSCodeDevTools` with your client instance. ```js -import { registerClient } from "@apollo/client-devtools-vscode"; +import { connectApolloClientToVSCodeDevTools } from "@apollo/client-devtools-vscode"; const client = new ApolloClient({ /* ... */ }); // we recommend wrapping this statement in a check for e.g. process.env.NODE_ENV === "development" -const devtoolsRegistration = registerClient( +const devtoolsRegistration = connectApolloClientToVSCodeDevTools( client, // the default port of the VSCode DevTools is 7095 "ws://localhost:7095", From 68a7b4a11ae89e8f19f0b7c76669289b9327009a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 12 Dec 2024 10:47:00 -0700 Subject: [PATCH 08/17] Remove `itAsync` part 1 (#12182) --- src/__tests__/ApolloClient.ts | 475 +- src/__tests__/client.ts | 4017 ++++++++--------- src/__tests__/local-state/general.ts | 1406 +++--- src/__tests__/local-state/resolvers.ts | 1450 +++--- .../inmemory/__tests__/fragmentMatcher.ts | 7 +- src/cache/inmemory/__tests__/writeToStore.ts | 423 +- src/core/__tests__/QueryManager/links.ts | 505 +-- src/core/__tests__/fetchPolicies.ts | 355 +- .../batch-http/__tests__/batchHttpLink.ts | 1093 ++--- src/link/batch/__tests__/batchLink.ts | 951 ++-- src/link/context/__tests__/index.ts | 247 +- src/link/error/__tests__/index.ts | 645 ++- src/link/http/__tests__/HttpLink.ts | 1804 ++++---- .../__tests__/persisted-queries.test.ts | 705 +-- src/link/ws/__tests__/webSocketLink.ts | 167 +- .../context/__tests__/ApolloConsumer.test.tsx | 11 +- .../__tests__/ssr/getDataFromTree.test.tsx | 153 +- .../hooks/__tests__/useMutation.test.tsx | 150 +- .../hooks/__tests__/useReactiveVar.test.tsx | 286 +- src/testing/internal/ObservableStream.ts | 15 +- src/testing/matchers/toEmitError.ts | 16 +- .../observables/__tests__/asyncMap.ts | 131 +- 22 files changed, 6862 insertions(+), 8150 deletions(-) diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index e147de233a4..b7c1e8eb39a 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -15,7 +15,6 @@ import { Observable } from "../utilities"; import { ApolloLink, FetchResult } from "../link/core"; import { HttpLink } from "../link/http"; import { createFragmentRegistry, InMemoryCache } from "../cache"; -import { itAsync } from "../testing"; import { ObservableStream, spyOnConsole } from "../testing/internal"; import { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { invariant } from "../utilities/globals"; @@ -1205,235 +1204,181 @@ describe("ApolloClient", () => { result.data?.people.friends[0].id; }); - itAsync( - "with a replacement of nested array (wq)", - (resolve, reject) => { - let count = 0; - const client = newClient(); - const observable = client.watchQuery({ query }); - const subscription = observable.subscribe({ - next(nextResult) { - ++count; - if (count === 1) { - expect(nextResult.data).toEqual(data); - expect(observable.getCurrentResult().data).toEqual(data); - - const readData = client.readQuery({ query }); - expect(readData).toEqual(data); - - // modify readData and writeQuery - const bestFriends = readData!.people.friends.filter( - (x) => x.type === "best" - ); - // this should re call next - client.writeQuery({ - query, - data: { - people: { - id: 1, - friends: bestFriends, - __typename: "Person", - }, - }, - }); - } else if (count === 2) { - const expectation = { - people: { - id: 1, - friends: [bestFriend], - __typename: "Person", - }, - }; - expect(nextResult.data).toEqual(expectation); - expect(client.readQuery({ query })).toEqual( - expectation - ); - subscription.unsubscribe(); - resolve(); - } + it("with a replacement of nested array (wq)", async () => { + const client = newClient(); + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitMatchedValue({ data }); + expect(observable.getCurrentResult().data).toEqual(data); + + const readData = client.readQuery({ query }); + expect(readData).toEqual(data); + + // modify readData and writeQuery + const bestFriends = readData!.people.friends.filter( + (x) => x.type === "best" + ); + // this should re call next + client.writeQuery({ + query, + data: { + people: { + id: 1, + friends: bestFriends, + __typename: "Person", }, - }); - } - ); + }, + }); - itAsync( - "with a value change inside a nested array (wq)", - (resolve, reject) => { - let count = 0; - const client = newClient(); - const observable = client.watchQuery({ query }); - observable.subscribe({ - next: (nextResult) => { - count++; - if (count === 1) { - expect(nextResult.data).toEqual(data); - expect(observable.getCurrentResult().data).toEqual(data); - - const readData = client.readQuery({ query }); - expect(readData).toEqual(data); - - // modify readData and writeQuery - const friends = readData!.people.friends.slice(); - friends[0] = { ...friends[0], type: "okayest" }; - friends[1] = { ...friends[1], type: "okayest" }; - - // this should re call next - client.writeQuery({ - query, - data: { - people: { - id: 1, - friends, - __typename: "Person", - }, - }, - }); - - setTimeout(() => { - if (count === 1) - reject( - new Error( - "writeFragment did not re-call observable with next value" - ) - ); - }, 250); - } + const expectation = { + people: { + id: 1, + friends: [bestFriend], + __typename: "Person", + }, + }; - if (count === 2) { - const expectation0 = { - ...bestFriend, - type: "okayest", - }; - const expectation1 = { - ...badFriend, - type: "okayest", - }; - const nextFriends = nextResult.data!.people.friends; - expect(nextFriends[0]).toEqual(expectation0); - expect(nextFriends[1]).toEqual(expectation1); - - const readFriends = client.readQuery({ query })!.people - .friends; - expect(readFriends[0]).toEqual(expectation0); - expect(readFriends[1]).toEqual(expectation1); - resolve(); - } + await expect(stream).toEmitMatchedValue({ data: expectation }); + expect(client.readQuery({ query })).toEqual(expectation); + }); + + it("with a value change inside a nested array (wq)", async () => { + const client = newClient(); + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitMatchedValue({ data }); + + expect(observable.getCurrentResult().data).toEqual(data); + + const readData = client.readQuery({ query }); + expect(readData).toEqual(data); + + // modify readData and writeQuery + const friends = readData!.people.friends.slice(); + friends[0] = { ...friends[0], type: "okayest" }; + friends[1] = { ...friends[1], type: "okayest" }; + + // this should re call next + client.writeQuery({ + query, + data: { + people: { + id: 1, + friends, + __typename: "Person", }, - }); - } - ); + }, + }); + + const expectation0 = { + ...bestFriend, + type: "okayest", + }; + const expectation1 = { + ...badFriend, + type: "okayest", + }; + + const nextResult = await stream.takeNext(); + const nextFriends = nextResult.data!.people.friends; + + expect(nextFriends[0]).toEqual(expectation0); + expect(nextFriends[1]).toEqual(expectation1); + + const readFriends = client.readQuery({ query })!.people.friends; + expect(readFriends[0]).toEqual(expectation0); + expect(readFriends[1]).toEqual(expectation1); + }); }); + describe("using writeFragment", () => { - itAsync( - "with a replacement of nested array (wf)", - (resolve, reject) => { - let count = 0; - const client = newClient(); - const observable = client.watchQuery({ query }); - observable.subscribe({ - next: (result) => { - count++; - if (count === 1) { - expect(result.data).toEqual(data); - expect(observable.getCurrentResult().data).toEqual(data); - const bestFriends = result.data!.people.friends.filter( - (x) => x.type === "best" - ); - // this should re call next - client.writeFragment({ - id: `Person${result.data!.people.id}`, - fragment: gql` - fragment bestFriends on Person { - friends { - id - } - } - `, - data: { - friends: bestFriends, - __typename: "Person", - }, - }); - - setTimeout(() => { - if (count === 1) - reject( - new Error( - "writeFragment did not re-call observable with next value" - ) - ); - }, 50); - } + it("with a replacement of nested array (wf)", async () => { + const client = newClient(); + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); - if (count === 2) { - expect(result.data!.people.friends).toEqual([bestFriend]); - resolve(); + { + const result = await stream.takeNext(); + + expect(result.data).toEqual(data); + expect(observable.getCurrentResult().data).toEqual(data); + + const bestFriends = result.data!.people.friends.filter( + (x) => x.type === "best" + ); + + // this should re call next + client.writeFragment({ + id: `Person${result.data!.people.id}`, + fragment: gql` + fragment bestFriends on Person { + friends { + id + } } + `, + data: { + friends: bestFriends, + __typename: "Person", }, }); } - ); - itAsync( - "with a value change inside a nested array (wf)", - (resolve, reject) => { - let count = 0; - const client = newClient(); - const observable = client.watchQuery({ query }); - observable.subscribe({ - next: (result) => { - count++; - if (count === 1) { - expect(result.data).toEqual(data); - expect(observable.getCurrentResult().data).toEqual(data); - const friends = result.data!.people.friends; - - // this should re call next - client.writeFragment({ - id: `Person${result.data!.people.id}`, - fragment: gql` - fragment bestFriends on Person { - friends { - id - type - } - } - `, - data: { - friends: [ - { ...friends[0], type: "okayest" }, - { ...friends[1], type: "okayest" }, - ], - __typename: "Person", - }, - }); - - setTimeout(() => { - if (count === 1) - reject( - new Error( - "writeFragment did not re-call observable with next value" - ) - ); - }, 50); - } + { + const result = await stream.takeNext(); + expect(result.data!.people.friends).toEqual([bestFriend]); + } + }); + + it("with a value change inside a nested array (wf)", async () => { + const client = newClient(); + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); - if (count === 2) { - const nextFriends = result.data!.people.friends; - expect(nextFriends[0]).toEqual({ - ...bestFriend, - type: "okayest", - }); - expect(nextFriends[1]).toEqual({ - ...badFriend, - type: "okayest", - }); - resolve(); + { + const result = await stream.takeNext(); + + expect(result.data).toEqual(data); + expect(observable.getCurrentResult().data).toEqual(data); + const friends = result.data!.people.friends; + + // this should re call next + client.writeFragment({ + id: `Person${result.data!.people.id}`, + fragment: gql` + fragment bestFriends on Person { + friends { + id + type + } } + `, + data: { + friends: [ + { ...friends[0], type: "okayest" }, + { ...friends[1], type: "okayest" }, + ], + __typename: "Person", }, }); } - ); + + { + const result = await stream.takeNext(); + const nextFriends = result.data!.people.friends; + + expect(nextFriends[0]).toEqual({ + ...bestFriend, + type: "okayest", + }); + expect(nextFriends[1]).toEqual({ + ...badFriend, + type: "okayest", + }); + } + }); }); }); }); @@ -2804,69 +2749,63 @@ describe("ApolloClient", () => { invariantDebugSpy.mockRestore(); }); - itAsync( - "should catch refetchQueries error when not caught explicitly", - (resolve, reject) => { - const linkFn = jest - .fn( - () => - new Observable((observer) => { - setTimeout(() => { - observer.error(new Error("refetch failed")); - }); - }) - ) - .mockImplementationOnce(() => { - setTimeout(refetchQueries); - return Observable.of(); - }); - - const client = new ApolloClient({ - link: new ApolloLink(linkFn), - cache: new InMemoryCache(), + it("should catch refetchQueries error when not caught explicitly", (done) => { + expect.assertions(2); + const linkFn = jest + .fn( + () => + new Observable((observer) => { + setTimeout(() => { + observer.error(new Error("refetch failed")); + }); + }) + ) + .mockImplementationOnce(() => { + setTimeout(refetchQueries); + return Observable.of(); }); - const query = gql` - query someData { - foo { - bar - } + const client = new ApolloClient({ + link: new ApolloLink(linkFn), + cache: new InMemoryCache(), + }); + + const query = gql` + query someData { + foo { + bar } - `; + } + `; - const observable = client.watchQuery({ - query, - fetchPolicy: "network-only", - }); + const observable = client.watchQuery({ + query, + fetchPolicy: "network-only", + }); - observable.subscribe({}); + observable.subscribe({}); - function refetchQueries() { - const result = client.refetchQueries({ - include: "all", - }); + function refetchQueries() { + const result = client.refetchQueries({ + include: "all", + }); - result.queries[0].subscribe({ - error() { - setTimeout(() => { - try { - expect(invariantDebugSpy).toHaveBeenCalledTimes(1); - expect(invariantDebugSpy).toHaveBeenCalledWith( - "In client.refetchQueries, Promise.all promise rejected with error %o", - new ApolloError({ - networkError: new Error("refetch failed"), - }) - ); - resolve(); - } catch (err) { - reject(err); - } - }); - }, - }); - } + result.queries[0].subscribe({ + error() { + setTimeout(() => { + expect(invariantDebugSpy).toHaveBeenCalledTimes(1); + expect(invariantDebugSpy).toHaveBeenCalledWith( + "In client.refetchQueries, Promise.all promise rejected with error %o", + new ApolloError({ + networkError: new Error("refetch failed"), + }) + ); + done(); + }); + }, + }); } - ); + }); }); describe.skip("type tests", () => { diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 686d1c078ee..181e7b8d373 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -36,7 +36,7 @@ import { } from "../cache"; import { ApolloError } from "../errors"; -import { itAsync, mockSingleLink, MockLink, wait } from "../testing"; +import { mockSingleLink, MockLink, wait } from "../testing"; import { ObservableStream, spyOnConsole } from "../testing/internal"; import { waitFor } from "@testing-library/react"; @@ -106,36 +106,33 @@ describe("client", () => { ); }); - itAsync( - "should allow for a single query to take place", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - __typename - } + it("should allow for a single query to take place", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name __typename } + __typename } - `; + } + `; - const data = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - __typename: "Person", - }, - ], - __typename: "People", - }, - }; + const data = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + __typename: "Person", + }, + ], + __typename: "People", + }, + }; - return clientRoundtrip(resolve, reject, query, { data }); - } - ); + await clientRoundtrip(query, { data }); + }); it("should allow a single query with an apollo-link enabled network interface", async () => { const query = gql` @@ -176,137 +173,132 @@ describe("client", () => { expect(actualResult.data).toEqual(data); }); - itAsync( - "should allow for a single query with complex default variables to take place", - (resolve, reject) => { - const query = gql` - query stuff( - $test: Input = { key1: ["value", "value2"], key2: { key3: 4 } } - ) { - allStuff(test: $test) { - people { - name - } + it("should allow for a single query with complex default variables to take place", async () => { + const query = gql` + query stuff( + $test: Input = { key1: ["value", "value2"], key2: { key3: 4 } } + ) { + allStuff(test: $test) { + people { + name } } - `; + } + `; - const result = { - allStuff: { - people: [ - { - name: "Luke Skywalker", - }, - { - name: "Jabba The Hutt", - }, - ], - }, - }; + const result = { + allStuff: { + people: [ + { + name: "Luke Skywalker", + }, + { + name: "Jabba The Hutt", + }, + ], + }, + }; - const variables = { - test: { key1: ["value", "value2"], key2: { key3: 4 } }, - }; + const variables = { + test: { key1: ["value", "value2"], key2: { key3: 4 } }, + }; - const link = mockSingleLink({ - request: { query, variables }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query, variables }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - const basic = client.query({ query, variables }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - }); + { + const actualResult = await client.query({ query, variables }); - const withDefault = client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - }); + expect(actualResult.data).toEqual(result); + } - return Promise.all([basic, withDefault]).then(resolve, reject); + { + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); } - ); + }); - itAsync( - "should allow for a single query with default values that get overridden with variables", - (resolve, reject) => { - const query = gql` - query people($first: Int = 1) { - allPeople(first: $first) { - people { - name - } + it("should allow for a single query with default values that get overridden with variables", async () => { + const query = gql` + query people($first: Int = 1) { + allPeople(first: $first) { + people { + name } } - `; + } + `; - const variables = { first: 1 }; - const override = { first: 2 }; + const variables = { first: 1 }; + const override = { first: 2 }; - const result = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, - }; + const result = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + ], + }, + }; - const overriddenResult = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - { - name: "Jabba The Hutt", - }, - ], - }, - }; + const overriddenResult = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + { + name: "Jabba The Hutt", + }, + ], + }, + }; - const link = mockSingleLink( - { - request: { query, variables }, - result: { data: result }, - }, - { - request: { query, variables: override }, - result: { data: overriddenResult }, - } - ).setOnError(reject); + const link = mockSingleLink( + { + request: { query, variables }, + result: { data: result }, + }, + { + request: { query, variables: override }, + result: { data: overriddenResult }, + } + ); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - const basic = client.query({ query, variables }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - }); + { + const actualResult = await client.query({ query, variables }); - const withDefault = client.query({ query }).then((actualResult) => { - return expect(actualResult.data).toEqual(result); - }); + expect(actualResult.data).toEqual(result); + } - const withOverride = client - .query({ query, variables: override }) - .then((actualResult) => { - return expect(actualResult.data).toEqual(overriddenResult); - }); + { + const actualResult = await client.query({ query }); - return Promise.all([basic, withDefault, withOverride]).then( - resolve, - reject - ); + expect(actualResult.data).toEqual(result); + } + + { + const actualResult = await client.query({ query, variables: override }); + + expect(actualResult.data).toEqual(overriddenResult); } - ); + }); - itAsync("should allow fragments on root query", (resolve, reject) => { + it("should allow fragments on root query", async () => { const query = gql` query { ...QueryFragment @@ -330,42 +322,39 @@ describe("client", () => { __typename: "Query", }; - return clientRoundtrip(resolve, reject, query, { data }, null); + return clientRoundtrip(query, { data }, null); }); - itAsync( - "should allow fragments on root query with ifm", - (resolve, reject) => { - const query = gql` - query { - ...QueryFragment - } + it("should allow fragments on root query with ifm", async () => { + const query = gql` + query { + ...QueryFragment + } - fragment QueryFragment on Query { - records { - id - name - __typename - } + fragment QueryFragment on Query { + records { + id + name __typename } - `; + __typename + } + `; - const data = { - records: [ - { id: 1, name: "One", __typename: "Record" }, - { id: 2, name: "Two", __typename: "Record" }, - ], - __typename: "Query", - }; + const data = { + records: [ + { id: 1, name: "One", __typename: "Record" }, + { id: 2, name: "Two", __typename: "Record" }, + ], + __typename: "Query", + }; - return clientRoundtrip(resolve, reject, query, { data }, null, { - Query: ["Record"], - }); - } - ); + await clientRoundtrip(query, { data }, null, { + Query: ["Record"], + }); + }); - itAsync("should merge fragments on root query", (resolve, reject) => { + it("should merge fragments on root query", async () => { // The fragment should be used after the selected fields for the query. // Otherwise, the results aren't merged. // see: https://github.com/apollographql/apollo-client/issues/1479 @@ -395,12 +384,12 @@ describe("client", () => { __typename: "Query", }; - return clientRoundtrip(resolve, reject, query, { data }, null, { + await clientRoundtrip(query, { data }, null, { Query: ["Record"], }); }); - itAsync("store can be rehydrated from the server", (resolve, reject) => { + it("store can be rehydrated from the server", async () => { const query = gql` query people { allPeople(first: 1) { @@ -424,7 +413,7 @@ describe("client", () => { const link = mockSingleLink({ request: { query }, result: { data }, - }).setOnError(reject); + }); const initialState: any = { data: { @@ -450,269 +439,238 @@ describe("client", () => { ), }); - return client - .query({ query }) - .then((result) => { - expect(result.data).toEqual(data); - expect(finalState.data).toEqual( - (client.cache as InMemoryCache).extract() - ); - }) - .then(resolve, reject); + const result = await client.query({ query }); + + expect(result.data).toEqual(data); + expect(finalState.data).toEqual((client.cache as InMemoryCache).extract()); }); - itAsync( - "store can be rehydrated from the server using the shadow method", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } + it("store can be rehydrated from the server using the shadow method", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name } } - `; + } + `; - const data = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, - }; + const data = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + ], + }, + }; - const link = mockSingleLink({ - request: { query }, - result: { data }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query }, + result: { data }, + }); - const initialState: any = { - data: { - ROOT_QUERY: { - 'allPeople({"first":1})': { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, + const initialState: any = { + data: { + ROOT_QUERY: { + 'allPeople({"first":1})': { + people: [ + { + name: "Luke Skywalker", + }, + ], }, - optimistic: [], }, - }; + optimistic: [], + }, + }; - const finalState = assign({}, initialState, {}); + const finalState = assign({}, initialState, {}); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }).restore( - initialState.data - ), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }).restore( + initialState.data + ), + }); - return client - .query({ query }) - .then((result) => { - expect(result.data).toEqual(data); - expect(finalState.data).toEqual(client.extract()); - }) - .then(resolve, reject); - } - ); + const result = await client.query({ query }); - itAsync( - "stores shadow of restore returns the same result as accessing the method directly on the cache", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } - } - } - `; + expect(result.data).toEqual(data); + expect(finalState.data).toEqual(client.extract()); + }); - const data = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, - }; - - const link = mockSingleLink({ - request: { query }, - result: { data }, - }).setOnError(reject); + it("stores shadow of restore returns the same result as accessing the method directly on the cache", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; - const initialState: any = { - data: { - 'ROOT_QUERY.allPeople({"first":"1"}).people.0': { + const data = { + allPeople: { + people: [ + { name: "Luke Skywalker", }, - 'ROOT_QUERY.allPeople({"first":1})': { - people: [ - { - type: "id", - generated: true, - id: 'ROOT_QUERY.allPeople({"first":"1"}).people.0', - }, - ], - }, - ROOT_QUERY: { - 'allPeople({"first":1})': { + ], + }, + }; + + const link = mockSingleLink({ + request: { query }, + result: { data }, + }); + + const initialState: any = { + data: { + 'ROOT_QUERY.allPeople({"first":"1"}).people.0': { + name: "Luke Skywalker", + }, + 'ROOT_QUERY.allPeople({"first":1})': { + people: [ + { type: "id", - id: 'ROOT_QUERY.allPeople({"first":1})', generated: true, + id: 'ROOT_QUERY.allPeople({"first":"1"}).people.0', }, + ], + }, + ROOT_QUERY: { + 'allPeople({"first":1})': { + type: "id", + id: 'ROOT_QUERY.allPeople({"first":1})', + generated: true, }, - optimistic: [], }, - }; - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }).restore( - initialState.data - ), - }); + optimistic: [], + }, + }; - expect(client.restore(initialState.data)).toEqual( - client.cache.restore(initialState.data) - ); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }).restore( + initialState.data + ), + }); - resolve(); - } - ); + expect(client.restore(initialState.data)).toEqual( + client.cache.restore(initialState.data) + ); + }); - itAsync( - "should return errors correctly for a single query", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } + it("should return errors correctly for a single query", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name } } - `; + } + `; - const errors: GraphQLError[] = [ - new GraphQLError( - "Syntax Error GraphQL request (8:9) Expected Name, found EOF" - ), - ]; + const errors: GraphQLError[] = [ + new GraphQLError( + "Syntax Error GraphQL request (8:9) Expected Name, found EOF" + ), + ]; - const link = mockSingleLink({ - request: { query }, - result: { errors }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query }, + result: { errors }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - return client - .query({ query }) - .catch((error: ApolloError) => { - expect(error.graphQLErrors).toEqual(errors); - }) - .then(resolve, reject); - } - ); + await expect(client.query({ query })).rejects.toEqual( + expect.objectContaining({ graphQLErrors: errors }) + ); + }); - itAsync( - "should return GraphQL errors correctly for a single query with an apollo-link enabled network interface", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } + it("should return GraphQL errors correctly for a single query with an apollo-link enabled network interface", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name } } - `; + } + `; - const data = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, - }; + const data = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + ], + }, + }; - const errors: GraphQLError[] = [ - new GraphQLError( - "Syntax Error GraphQL request (8:9) Expected Name, found EOF" - ), - ]; + const errors: GraphQLError[] = [ + new GraphQLError( + "Syntax Error GraphQL request (8:9) Expected Name, found EOF" + ), + ]; - const link = ApolloLink.from([ - () => { - return new Observable((observer) => { - observer.next({ data, errors }); - }); - }, - ]); + const link = ApolloLink.from([ + () => { + return new Observable((observer) => { + observer.next({ data, errors }); + }); + }, + ]); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - client.query({ query }).catch((error: ApolloError) => { - expect(error.graphQLErrors).toEqual(errors); - resolve(); - }); - } - ); + await expect(client.query({ query })).rejects.toEqual( + expect.objectContaining({ graphQLErrors: errors }) + ); + }); - itAsync( - "should pass a network error correctly on a query with apollo-link network interface", - (resolve, reject) => { - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } + it("should pass a network error correctly on a query with apollo-link network interface", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name } } - `; + } + `; - const networkError = new Error("Some kind of network error."); + const networkError = new Error("Some kind of network error."); - const link = ApolloLink.from([ - () => { - return new Observable((_) => { - throw networkError; - }); - }, - ]); + const link = ApolloLink.from([ + () => { + return new Observable((_) => { + throw networkError; + }); + }, + ]); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - client.query({ query }).catch((error: ApolloError) => { - expect(error.networkError).toBeDefined(); - expect(error.networkError!.message).toEqual(networkError.message); - resolve(); - }); - } - ); + await expect(client.query({ query })).rejects.toThrow( + new ApolloError({ networkError }) + ); + }); it("should not warn when receiving multiple results from apollo-link network interface", () => { const query = gql` @@ -747,117 +705,22 @@ describe("client", () => { }); }); - itAsync.skip( - "should surface errors in observer.next as uncaught", - (resolve, reject) => { - const expectedError = new Error("this error should not reach the store"); - const listeners = process.listeners("uncaughtException"); - const oldHandler = listeners[listeners.length - 1]; - const handleUncaught = (e: Error) => { - console.log(e); - process.removeListener("uncaughtException", handleUncaught); - if (typeof oldHandler === "function") - process.addListener("uncaughtException", oldHandler); - if (e === expectedError) { - resolve(); - } else { - reject(e); - } - }; - process.removeListener("uncaughtException", oldHandler); - process.addListener("uncaughtException", handleUncaught); - - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } - } - } - `; - - const data = { - allPeople: { - people: [ - { - name: "Luke Skywalker", - }, - ], - }, - }; - - const link = mockSingleLink({ - request: { query }, - result: { data }, - }).setOnError(reject); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); - - const handle = client.watchQuery({ query }); - - handle.subscribe({ - next() { - throw expectedError; - }, - }); - } - ); - - itAsync.skip( - "should surfaces errors in observer.error as uncaught", - (resolve, reject) => { - const expectedError = new Error("this error should not reach the store"); - const listeners = process.listeners("uncaughtException"); - const oldHandler = listeners[listeners.length - 1]; - const handleUncaught = (e: Error) => { - process.removeListener("uncaughtException", handleUncaught); + it.skip("should surface errors in observer.next as uncaught", async () => { + const expectedError = new Error("this error should not reach the store"); + const listeners = process.listeners("uncaughtException"); + const oldHandler = listeners[listeners.length - 1]; + const handleUncaught = (e: Error) => { + console.log(e); + process.removeListener("uncaughtException", handleUncaught); + if (typeof oldHandler === "function") process.addListener("uncaughtException", oldHandler); - if (e === expectedError) { - resolve(); - } else { - reject(e); - } - }; - process.removeListener("uncaughtException", oldHandler); - process.addListener("uncaughtException", handleUncaught); - - const query = gql` - query people { - allPeople(first: 1) { - people { - name - } - } - } - `; - - const link = mockSingleLink({ - request: { query }, - result: {}, - }).setOnError(reject); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); - - const handle = client.watchQuery({ query }); - handle.subscribe({ - next() { - reject(new Error("did not expect next to be called")); - }, - error() { - throw expectedError; - }, - }); - } - ); + if (e !== expectedError) { + throw e; + } + }; + process.removeListener("uncaughtException", oldHandler); + process.addListener("uncaughtException", handleUncaught); - itAsync("should allow for subscribing to a request", (resolve, reject) => { const query = gql` query people { allPeople(first: 1) { @@ -881,7 +744,7 @@ describe("client", () => { const link = mockSingleLink({ request: { query }, result: { data }, - }).setOnError(reject); + }); const client = new ApolloClient({ link, @@ -891,37 +754,118 @@ describe("client", () => { const handle = client.watchQuery({ query }); handle.subscribe({ - next(result) { - expect(result.data).toEqual(data); - resolve(); + next() { + throw expectedError; }, }); }); - itAsync("should be able to transform queries", (resolve, reject) => { - const query = gql` - query { - author { - firstName - lastName - } + it.skip("should surfaces errors in observer.error as uncaught", async () => { + const expectedError = new Error("this error should not reach the store"); + const listeners = process.listeners("uncaughtException"); + const oldHandler = listeners[listeners.length - 1]; + const handleUncaught = (e: Error) => { + process.removeListener("uncaughtException", handleUncaught); + process.addListener("uncaughtException", oldHandler); + if (e !== expectedError) { + throw e; } - `; - const transformedQuery = gql` - query { - author { - firstName - lastName - __typename + }; + process.removeListener("uncaughtException", oldHandler); + process.addListener("uncaughtException", handleUncaught); + + const query = gql` + query people { + allPeople(first: 1) { + people { + name + } } } `; - const result = { - author: { - firstName: "John", - lastName: "Smith", - }, + const link = mockSingleLink({ + request: { query }, + result: {}, + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); + + const handle = client.watchQuery({ query }); + handle.subscribe({ + next() { + throw new Error("did not expect next to be called"); + }, + error() { + throw expectedError; + }, + }); + }); + + it("should allow for subscribing to a request", async () => { + const query = gql` + query people { + allPeople(first: 1) { + people { + name + } + } + } + `; + + const data = { + allPeople: { + people: [ + { + name: "Luke Skywalker", + }, + ], + }, + }; + + const link = mockSingleLink({ + request: { query }, + result: { data }, + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); + + const handle = client.watchQuery({ query }); + const stream = new ObservableStream(handle); + + await expect(stream).toEmitMatchedValue({ data }); + }); + + it("should be able to transform queries", async () => { + const query = gql` + query { + author { + firstName + lastName + } + } + `; + const transformedQuery = gql` + query { + author { + firstName + lastName + __typename + } + } + `; + + const result = { + author: { + firstName: "John", + lastName: "Smith", + }, }; const transformedResult = { author: { @@ -941,79 +885,73 @@ describe("client", () => { result: { data: transformedResult }, }, false - ).setOnError(reject); + ); const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: true }), }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(transformedResult); - }) - .then(resolve, reject); + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(transformedResult); }); - itAsync( - "should be able to transform queries on network-only fetches", - (resolve, reject) => { - const query = gql` - query { - author { - firstName - lastName - } + it("should be able to transform queries on network-only fetches", async () => { + const query = gql` + query { + author { + firstName + lastName } - `; - const transformedQuery = gql` - query { - author { - firstName - lastName - __typename - } + } + `; + const transformedQuery = gql` + query { + author { + firstName + lastName + __typename } - `; - const result = { - author: { - firstName: "John", - lastName: "Smith", - }, - }; - const transformedResult = { - author: { - firstName: "John", - lastName: "Smith", - __typename: "Author", - }, - }; - const link = mockSingleLink( - { - request: { query }, - result: { data: result }, - }, - { - request: { query: transformedQuery }, - result: { data: transformedResult }, - }, - false - ).setOnError(reject); + } + `; + const result = { + author: { + firstName: "John", + lastName: "Smith", + }, + }; + const transformedResult = { + author: { + firstName: "John", + lastName: "Smith", + __typename: "Author", + }, + }; + const link = mockSingleLink( + { + request: { query }, + result: { data: result }, + }, + { + request: { query: transformedQuery }, + result: { data: transformedResult }, + }, + false + ); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: true }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: true }), + }); - return client - .query({ fetchPolicy: "network-only", query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(transformedResult); - }) - .then(resolve, reject); - } - ); + const actualResult = await client.query({ + fetchPolicy: "network-only", + query, + }); + + expect(actualResult.data).toEqual(transformedResult); + }); it("removes @client fields from the query before it reaches the link", async () => { const result: { current: Operation | undefined } = { @@ -1063,7 +1001,7 @@ describe("client", () => { expect(print(result.current!.query)).toEqual(print(transformedQuery)); }); - itAsync("should handle named fragments on mutations", (resolve, reject) => { + it("should handle named fragments on mutations", async () => { const mutation = gql` mutation { starAuthor(id: 12) { @@ -1091,117 +1029,64 @@ describe("client", () => { const link = mockSingleLink({ request: { query: mutation }, result: { data: result }, - }).setOnError(reject); + }); const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }), }); - return client - .mutate({ mutation }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - }); - - itAsync( - "should be able to handle named fragments on network-only queries", - (resolve, reject) => { - const query = gql` - fragment authorDetails on Author { - firstName - lastName - } - - query { - author { - __typename - ...authorDetails - } - } - `; - const result = { - author: { - __typename: "Author", - firstName: "John", - lastName: "Smith", - }, - }; - - const link = mockSingleLink({ - request: { query }, - result: { data: result }, - }).setOnError(reject); + const actualResult = await client.mutate({ mutation }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + expect(actualResult.data).toEqual(result); + }); - return client - .query({ fetchPolicy: "network-only", query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + it("should be able to handle named fragments on network-only queries", async () => { + const query = gql` + fragment authorDetails on Author { + firstName + lastName + } - itAsync( - "should be able to handle named fragments with multiple fragments", - (resolve, reject) => { - const query = gql` - query { - author { - __typename - ...authorDetails - ...moreDetails - } + query { + author { + __typename + ...authorDetails } + } + `; + const result = { + author: { + __typename: "Author", + firstName: "John", + lastName: "Smith", + }, + }; - fragment authorDetails on Author { - firstName - lastName - } + const link = mockSingleLink({ + request: { query }, + result: { data: result }, + }); - fragment moreDetails on Author { - address - } - `; - const result = { - author: { - __typename: "Author", - firstName: "John", - lastName: "Smith", - address: "1337 10th St.", - }, - }; + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - const link = mockSingleLink({ - request: { query }, - result: { data: result }, - }).setOnError(reject); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const actualResult = await client.query({ + fetchPolicy: "network-only", + query, + }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + expect(actualResult.data).toEqual(result); + }); - itAsync("should be able to handle named fragments", (resolve, reject) => { + it("should be able to handle named fragments with multiple fragments", async () => { const query = gql` query { author { __typename ...authorDetails + ...moreDetails } } @@ -1209,240 +1094,251 @@ describe("client", () => { firstName lastName } + + fragment moreDetails on Author { + address + } `; const result = { author: { __typename: "Author", firstName: "John", lastName: "Smith", + address: "1337 10th St.", }, }; const link = mockSingleLink({ request: { query }, result: { data: result }, - }).setOnError(reject); + }); const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }), }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - }); + const actualResult = await client.query({ query }); - itAsync( - "should be able to handle inlined fragments on an Interface type", - (resolve, reject) => { - const query = gql` - query items { - items { - ...ItemFragment - __typename - } - } + expect(actualResult.data).toEqual(result); + }); - fragment ItemFragment on Item { - id - __typename - ... on ColorItem { - color - __typename - } + it("should be able to handle named fragments", async () => { + const query = gql` + query { + author { + __typename + ...authorDetails } - `; - const result = { - items: [ - { - __typename: "ColorItem", - id: "27tlpoPeXm6odAxj3paGQP", - color: "red", - }, - { - __typename: "MonochromeItem", - id: "1t3iFLsHBm4c4RjOMdMgOO", - }, - ], - }; + } - const link = mockSingleLink({ - request: { query }, - result: { data: result }, - }).setOnError(reject); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - possibleTypes: { - Item: ["ColorItem", "MonochromeItem"], - }, - }), - }); - return client - .query({ query }) - .then((actualResult: any) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + fragment authorDetails on Author { + firstName + lastName + } + `; + const result = { + author: { + __typename: "Author", + firstName: "John", + lastName: "Smith", + }, + }; - itAsync( - "should be able to handle inlined fragments on an Interface type with introspection fragment matcher", - (resolve, reject) => { - const query = gql` - query items { - items { - ...ItemFragment - __typename - } - } + const link = mockSingleLink({ + request: { query }, + result: { data: result }, + }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - fragment ItemFragment on Item { - id - ... on ColorItem { - color - __typename - } + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + }); + + it("should be able to handle inlined fragments on an Interface type", async () => { + const query = gql` + query items { + items { + ...ItemFragment __typename } - `; - const result = { - items: [ - { - __typename: "ColorItem", - id: "27tlpoPeXm6odAxj3paGQP", - color: "red", - }, - { - __typename: "MonochromeItem", - id: "1t3iFLsHBm4c4RjOMdMgOO", - }, - ], - }; + } - const link = mockSingleLink({ - request: { query }, - result: { data: result }, - }).setOnError(reject); + fragment ItemFragment on Item { + id + __typename + ... on ColorItem { + color + __typename + } + } + `; + const result = { + items: [ + { + __typename: "ColorItem", + id: "27tlpoPeXm6odAxj3paGQP", + color: "red", + }, + { + __typename: "MonochromeItem", + id: "1t3iFLsHBm4c4RjOMdMgOO", + }, + ], + }; - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - possibleTypes: { - Item: ["ColorItem", "MonochromeItem"], - }, - }), - }); + const link = mockSingleLink({ + request: { query }, + result: { data: result }, + }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + possibleTypes: { + Item: ["ColorItem", "MonochromeItem"], + }, + }), + }); + const actualResult = await client.query({ query }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + expect(actualResult.data).toEqual(result); + }); - itAsync( - "should call updateQueries and update after mutation on query with inlined fragments on an Interface type", - (resolve, reject) => { - const query = gql` - query items { - items { - ...ItemFragment - __typename - } + it("should be able to handle inlined fragments on an Interface type with introspection fragment matcher", async () => { + const query = gql` + query items { + items { + ...ItemFragment + __typename } + } - fragment ItemFragment on Item { - id - ... on ColorItem { - color - __typename - } + fragment ItemFragment on Item { + id + ... on ColorItem { + color __typename } - `; - const result = { - items: [ - { - __typename: "ColorItem", - id: "27tlpoPeXm6odAxj3paGQP", - color: "red", - }, - { - __typename: "MonochromeItem", - id: "1t3iFLsHBm4c4RjOMdMgOO", - }, - ], - }; + __typename + } + `; + const result = { + items: [ + { + __typename: "ColorItem", + id: "27tlpoPeXm6odAxj3paGQP", + color: "red", + }, + { + __typename: "MonochromeItem", + id: "1t3iFLsHBm4c4RjOMdMgOO", + }, + ], + }; - const mutation = gql` - mutation myMutationName { - fortuneCookie + const link = mockSingleLink({ + request: { query }, + result: { data: result }, + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + possibleTypes: { + Item: ["ColorItem", "MonochromeItem"], + }, + }), + }); + + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + }); + + it("should call updateQueries and update after mutation on query with inlined fragments on an Interface type", async () => { + const query = gql` + query items { + items { + ...ItemFragment + __typename } - `; - const mutationResult = { - fortuneCookie: "The waiter spit in your food", - }; + } - const link = mockSingleLink( + fragment ItemFragment on Item { + id + ... on ColorItem { + color + __typename + } + __typename + } + `; + const result = { + items: [ { - request: { query }, - result: { data: result }, + __typename: "ColorItem", + id: "27tlpoPeXm6odAxj3paGQP", + color: "red", }, { - request: { query: mutation }, - result: { data: mutationResult }, - } - ).setOnError(reject); + __typename: "MonochromeItem", + id: "1t3iFLsHBm4c4RjOMdMgOO", + }, + ], + }; - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - possibleTypes: { - Item: ["ColorItem", "MonochromeItem"], - }, - }), - }); + const mutation = gql` + mutation myMutationName { + fortuneCookie + } + `; + const mutationResult = { + fortuneCookie: "The waiter spit in your food", + }; - const queryUpdaterSpy = jest.fn(); - const queryUpdater = (prev: any) => { - queryUpdaterSpy(); - return prev; - }; - const updateQueries = { - items: queryUpdater, - }; + const link = mockSingleLink( + { + request: { query }, + result: { data: result }, + }, + { + request: { query: mutation }, + result: { data: mutationResult }, + } + ); - const updateSpy = jest.fn(); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + possibleTypes: { + Item: ["ColorItem", "MonochromeItem"], + }, + }), + }); - const obs = client.watchQuery({ query }); + const queryUpdaterSpy = jest.fn(); + const queryUpdater = (prev: any) => { + queryUpdaterSpy(); + return prev; + }; + const updateQueries = { + items: queryUpdater, + }; - const sub = obs.subscribe({ - next() { - client - .mutate({ mutation, updateQueries, update: updateSpy }) - .then(() => { - expect(queryUpdaterSpy).toBeCalled(); - expect(updateSpy).toBeCalled(); - sub.unsubscribe(); - resolve(); - }) - .catch((err) => { - reject(err); - }); - }, - error(err) { - reject(err); - }, - }); - } - ); + const updateSpy = jest.fn(); + + const obs = client.watchQuery({ query }); + const stream = new ObservableStream(obs); + + await expect(stream).toEmitNext(); + await client.mutate({ mutation, updateQueries, update: updateSpy }); + + expect(queryUpdaterSpy).toBeCalled(); + expect(updateSpy).toBeCalled(); + }); it("should send operationName along with the query to the server", () => { const query = gql` @@ -1494,61 +1390,7 @@ describe("client", () => { }); }); - itAsync( - "does not deduplicate queries if option is set to false", - (resolve, reject) => { - const queryDoc = gql` - query { - author { - name - } - } - `; - const data = { - author: { - name: "Jonas", - }, - }; - const data2 = { - author: { - name: "Dhaivat", - }, - }; - - // we have two responses for identical queries, and both should be requested. - // the second one should make it through to the network interface. - const link = mockSingleLink( - { - request: { query: queryDoc }, - result: { data }, - delay: 10, - }, - { - request: { query: queryDoc }, - result: { data: data2 }, - } - ).setOnError(reject); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - queryDeduplication: false, - }); - - const q1 = client.query({ query: queryDoc }); - const q2 = client.query({ query: queryDoc }); - - // if deduplication happened, result2.data will equal data. - return Promise.all([q1, q2]) - .then(([result1, result2]) => { - expect(result1.data).toEqual(data); - expect(result2.data).toEqual(data2); - }) - .then(resolve, reject); - } - ); - - itAsync("deduplicates queries by default", (resolve, reject) => { + it("does not deduplicate queries if option is set to false", async () => { const queryDoc = gql` query { author { @@ -1567,8 +1409,8 @@ describe("client", () => { }, }; - // we have two responses for identical queries, but only the first should be requested. - // the second one should never make it through to the network interface. + // we have two responses for identical queries, and both should be requested. + // the second one should make it through to the network interface. const link = mockSingleLink( { request: { query: queryDoc }, @@ -1579,24 +1421,25 @@ describe("client", () => { request: { query: queryDoc }, result: { data: data2 }, } - ).setOnError(reject); + ); + const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }), + queryDeduplication: false, }); const q1 = client.query({ query: queryDoc }); const q2 = client.query({ query: queryDoc }); - // if deduplication didn't happen, result.data will equal data2. - return Promise.all([q1, q2]) - .then(([result1, result2]) => { - expect(result1.data).toEqual(result2.data); - }) - .then(resolve, reject); + // if deduplication happened, result2.data will equal data. + const [result1, result2] = await Promise.all([q1, q2]); + + expect(result1.data).toEqual(data); + expect(result2.data).toEqual(data2); }); - it("deduplicates queries if query context.queryDeduplication is set to true", () => { + it("deduplicates queries by default", async () => { const queryDoc = gql` query { author { @@ -1631,24 +1474,70 @@ describe("client", () => { const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }), - queryDeduplication: false, }); - // Both queries need to be deduplicated, otherwise only one gets tracked - const q1 = client.query({ - query: queryDoc, - context: { queryDeduplication: true }, - }); - const q2 = client.query({ - query: queryDoc, - context: { queryDeduplication: true }, - }); + const q1 = client.query({ query: queryDoc }); + const q2 = client.query({ query: queryDoc }); - // if deduplication happened, result2.data will equal data. - return Promise.all([q1, q2]).then(([result1, result2]) => { - expect(result1.data).toEqual(data); - expect(result2.data).toEqual(data); - }); + // if deduplication didn't happen, result.data will equal data2. + const [result1, result2] = await Promise.all([q1, q2]); + + expect(result1.data).toEqual(result2.data); + }); + + it("deduplicates queries if query context.queryDeduplication is set to true", () => { + const queryDoc = gql` + query { + author { + name + } + } + `; + const data = { + author: { + name: "Jonas", + }, + }; + const data2 = { + author: { + name: "Dhaivat", + }, + }; + + // we have two responses for identical queries, but only the first should be requested. + // the second one should never make it through to the network interface. + const link = mockSingleLink( + { + request: { query: queryDoc }, + result: { data }, + delay: 10, + }, + { + request: { query: queryDoc }, + result: { data: data2 }, + } + ); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + queryDeduplication: false, + }); + + // Both queries need to be deduplicated, otherwise only one gets tracked + const q1 = client.query({ + query: queryDoc, + context: { queryDeduplication: true }, + }); + const q2 = client.query({ + query: queryDoc, + context: { queryDeduplication: true }, + }); + + // if deduplication happened, result2.data will equal data. + return Promise.all([q1, q2]).then(([result1, result2]) => { + expect(result1.data).toEqual(data); + expect(result2.data).toEqual(data); + }); }); it("does not deduplicate queries if query context.queryDeduplication is set to false", () => { @@ -1702,53 +1591,53 @@ describe("client", () => { }); }); - itAsync( - "unsubscribes from deduplicated observables only once", - (resolve, reject) => { - const document: DocumentNode = gql` - query test1($x: String) { - test(x: $x) - } - `; + it("unsubscribes from deduplicated observables only once", async () => { + const document: DocumentNode = gql` + query test1($x: String) { + test(x: $x) + } + `; - const variables1 = { x: "Hello World" }; - const variables2 = { x: "Hello World" }; + const variables1 = { x: "Hello World" }; + const variables2 = { x: "Hello World" }; - let unsubscribed = false; + let unsubscribeCount = 0; - const client = new ApolloClient({ - link: new ApolloLink(() => { - return new Observable((observer) => { - observer.complete(); - return () => { - unsubscribed = true; - setTimeout(resolve, 0); - }; - }); - }), - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link: new ApolloLink(() => { + return new Observable((observer) => { + observer.complete(); + return () => { + unsubscribeCount++; + }; + }); + }), + cache: new InMemoryCache(), + }); - const sub1 = client - .watchQuery({ - query: document, - variables: variables1, - }) - .subscribe({}); + const sub1 = client + .watchQuery({ + query: document, + variables: variables1, + }) + .subscribe({}); - const sub2 = client - .watchQuery({ - query: document, - variables: variables2, - }) - .subscribe({}); + const sub2 = client + .watchQuery({ + query: document, + variables: variables2, + }) + .subscribe({}); - sub1.unsubscribe(); - expect(unsubscribed).toBe(false); + sub1.unsubscribe(); + // cleanup happens async + expect(unsubscribeCount).toBe(0); - sub2.unsubscribe(); - } - ); + sub2.unsubscribe(); + + await wait(0); + expect(unsubscribeCount).toBe(1); + }); describe("deprecated options", () => { const query = gql` @@ -1801,11 +1690,11 @@ describe("client", () => { }, }; - itAsync("for internal store", (resolve, reject) => { + it("for internal store", async () => { const link = mockSingleLink({ request: { query }, result: { data }, - }).setOnError(reject); + }); const client = new ApolloClient({ link, @@ -1816,16 +1705,13 @@ describe("client", () => { }), }); - return client - .query({ query }) - .then((result) => { - expect(result.data).toEqual(data); - expect((client.cache as InMemoryCache).extract()["1"]).toEqual({ - id: "1", - name: "Luke Skywalker", - }); - }) - .then(resolve, reject); + const result = await client.query({ query }); + + expect(result.data).toEqual(data); + expect((client.cache as InMemoryCache).extract()["1"]).toEqual({ + id: "1", + name: "Luke Skywalker", + }); }); }); @@ -1855,15 +1741,6 @@ describe("client", () => { "to receive multiple results from the cache and the network, or consider " + "using a different fetchPolicy, such as cache-first or network-only."; - function checkCacheAndNetworkError(callback: () => any) { - try { - callback(); - throw new Error("not reached"); - } catch (thrown) { - expect((thrown as Error).message).toBe(cacheAndNetworkError); - } - } - // Test that cache-and-network can only be used on watchQuery, not query. it("warns when used with client.query", () => { const client = new ApolloClient({ @@ -1871,12 +1748,12 @@ describe("client", () => { cache: new InMemoryCache(), }); - checkCacheAndNetworkError(() => - client.query({ + expect(() => { + void client.query({ query, fetchPolicy: "cache-and-network" as FetchPolicy, - }) - ); + }); + }).toThrow(new Error(cacheAndNetworkError)); }); it("warns when used with client.query with defaultOptions", () => { @@ -1890,14 +1767,15 @@ describe("client", () => { }, }); - checkCacheAndNetworkError(() => - client.query({ - query, - // This undefined value should be ignored in favor of - // defaultOptions.query.fetchPolicy. - fetchPolicy: void 0, - }) - ); + expect( + () => + void client.query({ + query, + // This undefined value should be ignored in favor of + // defaultOptions.query.fetchPolicy. + fetchPolicy: void 0, + }) + ).toThrow(new Error(cacheAndNetworkError)); }); it("fetches from cache first, then network", async () => { @@ -1950,7 +1828,7 @@ describe("client", () => { await expect(stream).not.toEmitAnything(); }); - itAsync("fails if network request fails", (resolve, reject) => { + it("fails if network request fails", async () => { const link = mockSingleLink(); // no queries = no replies. const client = new ApolloClient({ link, @@ -1961,59 +1839,42 @@ describe("client", () => { query, fetchPolicy: "cache-and-network", }); + const stream = new ObservableStream(obs); - obs.subscribe({ - error: (e) => { - if (!/No more mocked responses/.test(e.message)) { - reject(e); - } else { - resolve(); - } - }, - }); + const error = await stream.takeError(); + + expect(error.message).toMatch(/No more mocked responses/); }); - itAsync( - "fetches from cache first, then network and does not have an unhandled error", - (resolve, reject) => { - const link = mockSingleLink({ - request: { query }, - result: { errors: [{ message: "network failure" }] }, - }).setOnError(reject); + it("fetches from cache first, then network and does not have an unhandled error", async () => { + const link = mockSingleLink({ + request: { query }, + result: { errors: [{ message: "network failure" }] }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - client.writeQuery({ query, data: initialData }); + client.writeQuery({ query, data: initialData }); - const obs = client.watchQuery({ - query, - fetchPolicy: "cache-and-network", - }); - let shouldFail = true; - process.once("unhandledRejection", (rejection) => { - if (shouldFail) reject("promise had an unhandledRejection"); - }); - let count = 0; - obs.subscribe({ - next: (result) => { - expect(result.data).toEqual(initialData); - expect(result.loading).toBe(true); - count++; - }, - error: (e) => { - expect(e.message).toMatch(/network failure/); - expect(count).toBe(1); // make sure next was called. - setTimeout(() => { - shouldFail = false; - resolve(); - }, 0); - }, - }); - } - ); + const obs = client.watchQuery({ + query, + fetchPolicy: "cache-and-network", + }); + const stream = new ObservableStream(obs); + + await expect(stream).toEmitValue({ + loading: true, + data: initialData, + networkStatus: 1, + }); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/network failure/); + }); }); describe("standby queries", () => { @@ -2096,7 +1957,7 @@ describe("client", () => { }, }; - function makeLink(reject: (reason: any) => any) { + function makeLink() { return mockSingleLink( { request: { query }, @@ -2106,31 +1967,26 @@ describe("client", () => { request: { query }, result: { data: secondFetch }, } - ).setOnError(reject); + ); } - itAsync("forces the query to rerun", (resolve, reject) => { + it("forces the query to rerun", async () => { const client = new ApolloClient({ - link: makeLink(reject), + link: makeLink(), cache: new InMemoryCache({ addTypename: false }), }); // Run a query first to initialize the store - return ( - client - .query({ query }) - // then query for real - .then(() => client.query({ query, fetchPolicy: "network-only" })) - .then((result) => { - expect(result.data).toEqual({ myNumber: { n: 2 } }); - }) - .then(resolve, reject) - ); + await client.query({ query }); + // then query for real + const result = await client.query({ query, fetchPolicy: "network-only" }); + + expect(result.data).toEqual({ myNumber: { n: 2 } }); }); - itAsync("can be disabled with ssrMode", (resolve, reject) => { + it("can be disabled with ssrMode", async () => { const client = new ApolloClient({ - link: makeLink(reject), + link: makeLink(), ssrMode: true, cache: new InMemoryCache({ addTypename: false }), }); @@ -2138,185 +1994,76 @@ describe("client", () => { const options: QueryOptions = { query, fetchPolicy: "network-only" }; // Run a query first to initialize the store - return ( - client - .query({ query }) - // then query for real - .then(() => client.query(options)) - .then((result) => { - expect(result.data).toEqual({ myNumber: { n: 1 } }); - // Test that options weren't mutated, issue #339 - expect(options).toEqual({ - query, - fetchPolicy: "network-only", - }); - }) - .then(resolve, reject) - ); - }); + await client.query({ query }); + // then query for real + const result = await client.query(options); - itAsync( - "can temporarily be disabled with ssrForceFetchDelay", - (resolve, reject) => { - const client = new ApolloClient({ - link: makeLink(reject), - ssrForceFetchDelay: 100, - cache: new InMemoryCache({ addTypename: false }), - }); - - // Run a query first to initialize the store - return ( - client - .query({ query }) - // then query for real - .then(() => { - return client.query({ query, fetchPolicy: "network-only" }); - }) - .then(async (result) => { - expect(result.data).toEqual({ myNumber: { n: 1 } }); - await new Promise((resolve) => setTimeout(resolve, 100)); - return client.query({ query, fetchPolicy: "network-only" }); - }) - .then((result) => { - expect(result.data).toEqual({ myNumber: { n: 2 } }); - }) - .then(resolve, reject) - ); - } - ); - }); + expect(result.data).toEqual({ myNumber: { n: 1 } }); + // Test that options weren't mutated, issue #339 + expect(options).toEqual({ + query, + fetchPolicy: "network-only", + }); + }); - itAsync( - "should pass a network error correctly on a mutation", - (resolve, reject) => { - const mutation = gql` - mutation { - person { - firstName - lastName - } - } - `; - const data = { - person: { - firstName: "John", - lastName: "Smith", - }, - }; - const networkError = new Error("Some kind of network error."); + it("can temporarily be disabled with ssrForceFetchDelay", async () => { const client = new ApolloClient({ - link: mockSingleLink({ - request: { query: mutation }, - result: { data }, - error: networkError, - }), + link: makeLink(), + ssrForceFetchDelay: 100, cache: new InMemoryCache({ addTypename: false }), }); - client - .mutate({ mutation }) - .then((_) => { - reject(new Error("Returned a result when it should not have.")); - }) - .catch((error: ApolloError) => { - expect(error.networkError).toBeDefined(); - expect(error.networkError!.message).toBe(networkError.message); - resolve(); + // Run a query first to initialize the store + await client.query({ query }); + // then query for real + { + const result = await client.query({ + query, + fetchPolicy: "network-only", }); - } - ); - itAsync( - "should pass a GraphQL error correctly on a mutation", - (resolve, reject) => { - const mutation = gql` - mutation { - newPerson { - person { - firstName - lastName - } - } - } - `; - const data = { - person: { - firstName: "John", - lastName: "Smith", - }, - }; - const errors = [new Error("Some kind of GraphQL error.")]; - const client = new ApolloClient({ - link: mockSingleLink({ - request: { query: mutation }, - result: { data, errors }, - }).setOnError(reject), - cache: new InMemoryCache({ addTypename: false }), - }); - client - .mutate({ mutation }) - .then((_) => { - reject(new Error("Returned a result when it should not have.")); - }) - .catch((error: ApolloError) => { - expect(error.graphQLErrors).toBeDefined(); - expect(error.graphQLErrors.length).toBe(1); - expect(error.graphQLErrors[0].message).toBe(errors[0].message); - resolve(); - }); - } - ); + expect(result.data).toEqual({ myNumber: { n: 1 } }); + } - itAsync( - "should allow errors to be returned from a mutation", - (resolve, reject) => { - const mutation = gql` - mutation { - newPerson { - person { - firstName - lastName - } - } + await wait(100); + + const result = await client.query({ query, fetchPolicy: "network-only" }); + + expect(result.data).toEqual({ myNumber: { n: 2 } }); + }); + }); + + it("should pass a network error correctly on a mutation", async () => { + const mutation = gql` + mutation { + person { + firstName + lastName } - `; - const data = { - person: { - firstName: "John", - lastName: "Smith", - }, - }; - const errors = [new Error("Some kind of GraphQL error.")]; - const client = new ApolloClient({ - link: mockSingleLink({ - request: { query: mutation }, - result: { - errors, - data: { - newPerson: data, - }, - }, - }).setOnError(reject), - cache: new InMemoryCache({ addTypename: false }), - }); - client - .mutate({ mutation, errorPolicy: "all" }) - .then((result) => { - expect(result.errors).toBeDefined(); - expect(result.errors!.length).toBe(1); - expect(result.errors![0].message).toBe(errors[0].message); - expect(result.data).toEqual({ - newPerson: data, - }); - resolve(); - }) - .catch((error: ApolloError) => { - throw error; - }); - } - ); + } + `; + const data = { + person: { + firstName: "John", + lastName: "Smith", + }, + }; + const networkError = new Error("Some kind of network error."); + const client = new ApolloClient({ + link: mockSingleLink({ + request: { query: mutation }, + result: { data }, + error: networkError, + }), + cache: new InMemoryCache({ addTypename: false }), + }); + + await expect(client.mutate({ mutation })).rejects.toThrow( + new ApolloError({ networkError }) + ); + }); - itAsync("should strip errors on a mutation if ignored", (resolve, reject) => { + it("should pass a GraphQL error correctly on a mutation", async () => { const mutation = gql` mutation { newPerson { @@ -2328,11 +2075,9 @@ describe("client", () => { } `; const data = { - newPerson: { - person: { - firstName: "John", - lastName: "Smith", - }, + person: { + firstName: "John", + lastName: "Smith", }, }; const errors = [new Error("Some kind of GraphQL error.")]; @@ -2340,80 +2085,144 @@ describe("client", () => { link: mockSingleLink({ request: { query: mutation }, result: { data, errors }, - }).setOnError(reject), + }), cache: new InMemoryCache({ addTypename: false }), }); - client - .mutate({ mutation, errorPolicy: "ignore" }) - .then((result) => { - expect(result.errors).toBeUndefined(); - expect(result.data).toEqual(data); - resolve(); - }) - .catch((error: ApolloError) => { - throw error; - }); + + await expect(client.mutate({ mutation })).rejects.toEqual( + expect.objectContaining({ graphQLErrors: errors }) + ); }); - itAsync( - "should rollback optimistic after mutation got a GraphQL error", - (resolve, reject) => { - const mutation = gql` - mutation { - newPerson { - person { - firstName - lastName - } - } - } - `; - const data = { - newPerson: { - person: { - firstName: "John", - lastName: "Smith", + it("should allow errors to be returned from a mutation", async () => { + const mutation = gql` + mutation { + newPerson { + person { + firstName + lastName + } + } + } + `; + const data = { + person: { + firstName: "John", + lastName: "Smith", + }, + }; + const errors = [new Error("Some kind of GraphQL error.")]; + const client = new ApolloClient({ + link: mockSingleLink({ + request: { query: mutation }, + result: { + errors, + data: { + newPerson: data, }, }, - }; - const errors = [new Error("Some kind of GraphQL error.")]; - const client = new ApolloClient({ - link: mockSingleLink({ - request: { query: mutation }, - result: { data, errors }, - }).setOnError(reject), - cache: new InMemoryCache({ addTypename: false }), - }); - const mutatePromise = client.mutate({ - mutation, - optimisticResponse: { - newPerson: { - person: { - firstName: "John*", - lastName: "Smith*", - }, - }, + }), + cache: new InMemoryCache({ addTypename: false }), + }); + + const result = await client.mutate({ mutation, errorPolicy: "all" }); + + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBe(1); + expect(result.errors![0].message).toBe(errors[0].message); + expect(result.data).toEqual({ + newPerson: data, + }); + }); + + it("should strip errors on a mutation if ignored", async () => { + const mutation = gql` + mutation { + newPerson { + person { + firstName + lastName + } + } + } + `; + const data = { + newPerson: { + person: { + firstName: "John", + lastName: "Smith", }, - }); + }, + }; + const errors = [new Error("Some kind of GraphQL error.")]; + const client = new ApolloClient({ + link: mockSingleLink({ + request: { query: mutation }, + result: { data, errors }, + }), + cache: new InMemoryCache({ addTypename: false }), + }); - { - const { data, optimisticData } = client.cache as any; - expect(optimisticData).not.toBe(data); - expect(optimisticData.parent).toBe(data.stump); - expect(optimisticData.parent.parent).toBe(data); + const result = await client.mutate({ mutation, errorPolicy: "ignore" }); + + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual(data); + }); + + it("should rollback optimistic after mutation got a GraphQL error", async () => { + const mutation = gql` + mutation { + newPerson { + person { + firstName + lastName + } + } } + `; + const data = { + newPerson: { + person: { + firstName: "John", + lastName: "Smith", + }, + }, + }; + const errors = [new Error("Some kind of GraphQL error.")]; + const client = new ApolloClient({ + link: mockSingleLink({ + request: { query: mutation }, + result: { data, errors }, + }), + cache: new InMemoryCache({ addTypename: false }), + }); + const mutatePromise = client.mutate({ + mutation, + optimisticResponse: { + newPerson: { + person: { + firstName: "John*", + lastName: "Smith*", + }, + }, + }, + }); - mutatePromise - .then((_) => { - reject(new Error("Returned a result when it should not have.")); - }) - .catch((_: ApolloError) => { - const { data, optimisticData } = client.cache as any; - expect(optimisticData).toBe(data.stump); - resolve(); - }); + { + const { data, optimisticData } = client.cache as any; + expect(optimisticData).not.toBe(data); + expect(optimisticData.parent).toBe(data.stump); + expect(optimisticData.parent.parent).toBe(data); } - ); + + await expect(mutatePromise).rejects.toThrow(); + + { + const { data, optimisticData } = client.cache as any; + + expect(optimisticData).toBe(data.stump); + } + }); it("has a clearStore method which calls QueryManager", async () => { const client = new ApolloClient({ @@ -2526,100 +2335,83 @@ describe("client", () => { expect(count).toEqual(2); }); - itAsync( - "invokes onResetStore callbacks before notifying queries during resetStore call", - async (resolve, reject) => { - const delay = (time: number) => new Promise((r) => setTimeout(r, time)); + it("invokes onResetStore callbacks before notifying queries during resetStore call", async () => { + const delay = (time: number) => new Promise((r) => setTimeout(r, time)); - const query = gql` - query { - author { - firstName - lastName - } + const query = gql` + query { + author { + firstName + lastName } - `; + } + `; - const data = { - author: { - __typename: "Author", - firstName: "John", - lastName: "Smith", - }, - }; + const data = { + author: { + __typename: "Author", + firstName: "John", + lastName: "Smith", + }, + }; - const data2 = { - author: { - __typename: "Author", - firstName: "Joe", - lastName: "Joe", - }, - }; + const data2 = { + author: { + __typename: "Author", + firstName: "Joe", + lastName: "Joe", + }, + }; - const link = ApolloLink.from([ - new ApolloLink( - () => - new Observable((observer) => { - observer.next({ data }); - observer.complete(); - return; - }) - ), - ]); + const link = ApolloLink.from([ + new ApolloLink( + () => + new Observable((observer) => { + observer.next({ data }); + observer.complete(); + return; + }) + ), + ]); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); - let count = 0; - const onResetStoreOne = jest.fn(async () => { - expect(count).toEqual(0); - await delay(10).then(() => count++); - expect(count).toEqual(1); - }); + let count = 0; + const onResetStoreOne = jest.fn(async () => { + expect(count).toEqual(0); + await delay(10).then(() => count++); + expect(count).toEqual(1); + }); - const onResetStoreTwo = jest.fn(async () => { - expect(count).toEqual(0); - await delay(11).then(() => count++); - expect(count).toEqual(2); - expect(client.readQuery({ query })).toBe(null); - client.cache.writeQuery({ query, data: data2 }); - }); + const onResetStoreTwo = jest.fn(async () => { + expect(count).toEqual(0); + await delay(11).then(() => count++); + expect(count).toEqual(2); + expect(client.readQuery({ query })).toBe(null); + client.cache.writeQuery({ query, data: data2 }); + }); - client.onResetStore(onResetStoreOne); - client.onResetStore(onResetStoreTwo); + client.onResetStore(onResetStoreOne); + client.onResetStore(onResetStoreTwo); - let called = false; - const next = jest.fn((d) => { - if (called) { - expect(onResetStoreOne).toHaveBeenCalled(); - } else { - expect(d.data).toEqual(data); - called = true; - } - }); + const observable = client.watchQuery({ + query, + notifyOnNetworkStatusChange: false, + }); + const stream = new ObservableStream(observable); - client - .watchQuery({ - query, - notifyOnNetworkStatusChange: false, - }) - .subscribe({ - next, - error: reject, - complete: reject, - }); + expect(count).toBe(0); + await client.resetStore(); + expect(count).toBe(2); - expect(count).toEqual(0); - await client.resetStore(); - expect(count).toEqual(2); - //watchQuery should only receive data twice - expect(next).toHaveBeenCalledTimes(2); + await expect(stream).toEmitMatchedValue({ data }); + await expect(stream).toEmitNext(); - resolve(); - } - ); + expect(onResetStoreOne).toHaveBeenCalled(); + }); it("has a reFetchObservableQueries method which calls QueryManager", async () => { const client = new ApolloClient({ @@ -2690,845 +2482,758 @@ describe("client", () => { expect(spy).toHaveBeenCalled(); }); - itAsync( - "should propagate errors from network interface to observers", - (resolve, reject) => { - const link = ApolloLink.from([ - () => - new Observable((x) => { - x.error(new Error("Uh oh!")); - return; - }), - ]); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + it("should propagate errors from network interface to observers", async () => { + const link = ApolloLink.from([ + () => + new Observable((x) => { + x.error(new Error("Uh oh!")); + return; + }), + ]); - const handle = client.watchQuery({ - query: gql` - query { - a - b - c - } - `, - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); - handle.subscribe({ - error(error) { - expect(error.message).toBe("Uh oh!"); - resolve(); - }, - }); - } - ); - - itAsync( - "should be able to refetch after there was a network error", - (resolve, reject) => { - const query: DocumentNode = gql` - query somethingelse { - allPeople(first: 1) { - people { - name - } - } + const handle = client.watchQuery({ + query: gql` + query { + a + b + c } - `; + `, + }); - const data = { allPeople: { people: [{ name: "Luke Skywalker" }] } }; - const dataTwo = { allPeople: { people: [{ name: "Princess Leia" }] } }; - const link = mockSingleLink( - { request: { query }, result: { data } }, - { request: { query }, error: new Error("This is an error!") }, - { request: { query }, result: { data: dataTwo } } - ); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - }); + const stream = new ObservableStream(handle); - let count = 0; - const noop = () => null; + const error = await stream.takeError(); - const observable = client.watchQuery({ - query, - notifyOnNetworkStatusChange: true, - }); + expect(error.message).toBe("Uh oh!"); + }); - let subscription: any = null; - - const observerOptions = { - next(result: any) { - try { - switch (count++) { - case 0: - if (!result.data!.allPeople) { - reject("Should have data by this point"); - break; - } - // First result is loaded, run a refetch to get the second result - // which is an error. - expect(result.loading).toBeFalsy(); - expect(result.networkStatus).toBe(7); - expect(result.data!.allPeople).toEqual(data.allPeople); - setTimeout(() => { - observable.refetch().then(() => { - reject("Expected error value on first refetch."); - }, noop); - }, 0); - break; - case 1: - // Waiting for the second result to load - expect(result.loading).toBeTruthy(); - expect(result.networkStatus).toBe(4); - break; - // case 2 is handled by the error callback - case 3: - expect(result.loading).toBeTruthy(); - expect(result.networkStatus).toBe(4); - expect(result.errors).toBeFalsy(); - break; - case 4: - // Third result's data is loaded - expect(result.loading).toBeFalsy(); - expect(result.networkStatus).toBe(7); - expect(result.errors).toBeFalsy(); - if (!result.data) { - reject("Should have data by this point"); - break; - } - expect(result.data.allPeople).toEqual(dataTwo.allPeople); - resolve(); - break; - default: - throw new Error("Unexpected fall through"); - } - } catch (e) { - reject(e); + it("should be able to refetch after there was a network error", async () => { + const query: DocumentNode = gql` + query somethingelse { + allPeople(first: 1) { + people { + name } - }, - error(error: Error) { - expect(count++).toBe(2); - expect(error.message).toBe("This is an error!"); - - subscription.unsubscribe(); - - const lastError = observable.getLastError(); - expect(lastError).toBeInstanceOf(ApolloError); - expect(lastError!.networkError).toEqual((error as any).networkError); - - const lastResult = observable.getLastResult(); - expect(lastResult).toBeTruthy(); - expect(lastResult!.loading).toBe(false); - expect(lastResult!.networkStatus).toBe(8); - - observable.resetLastResults(); - subscription = observable.subscribe(observerOptions); - - // The error arrived, run a refetch to get the third result - // which should now contain valid data. - setTimeout(() => { - observable.refetch().catch(() => { - reject("Expected good data on second refetch."); - }); - }, 0); - }, - }; - - subscription = observable.subscribe(observerOptions); - } - ); - - itAsync("should throw a GraphQL error", (resolve, reject) => { - const query = gql` - query { - posts { - foo - __typename } } `; - const errors: GraphQLError[] = [ - new GraphQLError('Cannot query field "foo" on type "Post".'), - ]; - const link = mockSingleLink({ - request: { query }, - result: { errors }, - }).setOnError(reject); + + const data = { allPeople: { people: [{ name: "Luke Skywalker" }] } }; + const dataTwo = { allPeople: { people: [{ name: "Princess Leia" }] } }; + const link = mockSingleLink( + { request: { query }, result: { data } }, + { request: { query }, error: new Error("This is an error!") }, + { request: { query }, result: { data: dataTwo } } + ); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); + + const observable = client.watchQuery({ + query, + notifyOnNetworkStatusChange: true, + }); + + let stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ + loading: false, + networkStatus: NetworkStatus.ready, + data, + }); + + await wait(0); + await expect(observable.refetch()).rejects.toThrow(); + + await expect(stream).toEmitValue({ + loading: true, + networkStatus: NetworkStatus.refetch, + data, + }); + + const error = await stream.takeError(); + + expect(error.message).toBe("This is an error!"); + + stream.unsubscribe(); + + const lastError = observable.getLastError(); + expect(lastError).toBeInstanceOf(ApolloError); + expect(lastError!.networkError).toEqual((error as any).networkError); + + const lastResult = observable.getLastResult(); + expect(lastResult).toBeTruthy(); + expect(lastResult!.loading).toBe(false); + expect(lastResult!.networkStatus).toBe(8); + + observable.resetLastResults(); + stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ + loading: false, + networkStatus: NetworkStatus.ready, + data, + }); + + await wait(0); + await expect(observable.refetch()).resolves.toBeTruthy(); + + await expect(stream).toEmitValue({ + loading: true, + networkStatus: NetworkStatus.refetch, + data, + }); + + await expect(stream).toEmitValue({ + loading: false, + networkStatus: NetworkStatus.ready, + errors: undefined, + data: dataTwo, + }); + + await expect(stream).not.toEmitAnything(); + }); + + it("should throw a GraphQL error", async () => { + const query = gql` + query { + posts { + foo + __typename + } + } + `; + const errors: GraphQLError[] = [ + new GraphQLError('Cannot query field "foo" on type "Post".'), + ]; + const link = mockSingleLink({ + request: { query }, + result: { errors }, + }); const client = new ApolloClient({ link, cache: new InMemoryCache(), }); - return client - .query({ query }) - .catch((err) => { - expect(err.message).toBe('Cannot query field "foo" on type "Post".'); - }) - .then(resolve, reject); + await expect(client.query({ query })).rejects.toThrow( + 'Cannot query field "foo" on type "Post".' + ); }); it("should warn if server returns wrong data", async () => { using _consoleSpies = spyOnConsole.takeSnapshots("error"); - await new Promise((resolve, reject) => { - const query = gql` - query { - todos { - id - name - description - __typename - } + const query = gql` + query { + todos { + id + name + description + __typename } - `; - const result = { - data: { - todos: [ - { - id: "1", - name: "Todo 1", - price: 100, - __typename: "Todo", - }, - ], - }, - }; - - const link = mockSingleLink({ - request: { query }, - result, - }).setOnError(reject); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - // Passing an empty map enables the warning: - possibleTypes: {}, - }), - }); + } + `; + const result = { + data: { + todos: [ + { + id: "1", + name: "Todo 1", + price: 100, + __typename: "Todo", + }, + ], + }, + }; - return client - .query({ query }) - .then(({ data }) => { - expect(data).toEqual(result.data); - }) - .then(resolve, reject); + const link = mockSingleLink({ + request: { query }, + result, + }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + // Passing an empty map enables the warning: + possibleTypes: {}, + }), }); + + const { data } = await client.query({ query }); + + expect(data).toEqual(result.data); }); - itAsync( - "runs a query with the connection directive and writes it to the store key defined in the directive", - (resolve, reject) => { - const query = gql` - { - books(skip: 0, limit: 2) @connection(key: "abc") { - name - } + it("runs a query with the connection directive and writes it to the store key defined in the directive", async () => { + const query = gql` + { + books(skip: 0, limit: 2) @connection(key: "abc") { + name } - `; + } + `; - const transformedQuery = gql` - { - books(skip: 0, limit: 2) { - name - __typename - } + const transformedQuery = gql` + { + books(skip: 0, limit: 2) { + name + __typename } - `; + } + `; - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const link = mockSingleLink({ - request: { query: transformedQuery }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query: transformedQuery }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + const actualResult = await client.query({ query }); - itAsync( - "runs query with cache field policy analogous to @connection", - (resolve, reject) => { - const query = gql` - { - books(skip: 0, limit: 2) { - name - } + expect(actualResult.data).toEqual(result); + }); + + it("runs query with cache field policy analogous to @connection", async () => { + const query = gql` + { + books(skip: 0, limit: 2) { + name } - `; + } + `; - const transformedQuery = gql` - { - books(skip: 0, limit: 2) { - name - __typename - } + const transformedQuery = gql` + { + books(skip: 0, limit: 2) { + name + __typename } - `; + } + `; - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const link = mockSingleLink({ - request: { query: transformedQuery }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query: transformedQuery }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - typePolicies: { - Query: { - fields: { - books: { - keyArgs: () => "abc", - }, + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + books: { + keyArgs: () => "abc", }, }, }, - }), - }); + }, + }), + }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + const actualResult = await client.query({ query }); - itAsync( - "should remove the connection directive before the link is sent", - (resolve, reject) => { - const query = gql` - { - books(skip: 0, limit: 2) @connection(key: "books") { - name - } + expect(actualResult.data).toEqual(result); + }); + + it("should remove the connection directive before the link is sent", async () => { + const query = gql` + { + books(skip: 0, limit: 2) @connection(key: "books") { + name } - `; + } + `; - const transformedQuery = gql` - { - books(skip: 0, limit: 2) { - name - __typename - } + const transformedQuery = gql` + { + books(skip: 0, limit: 2) { + name + __typename } - `; + } + `; - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const link = mockSingleLink({ - request: { query: transformedQuery }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query: transformedQuery }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - }) - .then(resolve, reject); - } - ); + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + }); }); describe("@connection", () => { - itAsync( - "should run a query with the @connection directive and write the result to the store key defined in the directive", - (resolve, reject) => { - const query = gql` - { - books(skip: 0, limit: 2) @connection(key: "abc") { - name - } + it("should run a query with the @connection directive and write the result to the store key defined in the directive", async () => { + const query = gql` + { + books(skip: 0, limit: 2) @connection(key: "abc") { + name } - `; + } + `; - const transformedQuery = gql` - { - books(skip: 0, limit: 2) { - name - __typename - } + const transformedQuery = gql` + { + books(skip: 0, limit: 2) { + name + __typename } - `; + } + `; - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const link = mockSingleLink({ - request: { query: transformedQuery }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query: transformedQuery }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const actualResult = await client.query({ query }); - return client - .query({ query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); - }) - .then(resolve, reject); - } - ); + expect(actualResult.data).toEqual(result); + expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); + }); - itAsync( - "should run a query with the connection directive and filter arguments and write the result to the correct store key", - (resolve, reject) => { - const query = gql` - query books($order: string) { - books(skip: 0, limit: 2, order: $order) - @connection(key: "abc", filter: ["order"]) { - name - } + it("should run a query with the connection directive and filter arguments and write the result to the correct store key", async () => { + const query = gql` + query books($order: string) { + books(skip: 0, limit: 2, order: $order) + @connection(key: "abc", filter: ["order"]) { + name } - `; - const transformedQuery = gql` - query books($order: string) { - books(skip: 0, limit: 2, order: $order) { - name - __typename - } + } + `; + const transformedQuery = gql` + query books($order: string) { + books(skip: 0, limit: 2, order: $order) { + name + __typename } - `; + } + `; - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const variables = { order: "popularity" }; + const variables = { order: "popularity" }; - const link = mockSingleLink({ - request: { query: transformedQuery, variables }, - result: { data: result }, - }).setOnError(reject); + const link = mockSingleLink({ + request: { query: transformedQuery, variables }, + result: { data: result }, + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); - return client - .query({ query, variables }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); - }) - .then(resolve, reject); - } - ); + const actualResult = await client.query({ query, variables }); - itAsync( - "should support cache field policies that filter key arguments", - (resolve, reject) => { - const query = gql` - query books($order: string) { - books(skip: 0, limit: 2, order: $order) { - name - } + expect(actualResult.data).toEqual(result); + expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); + }); + + it("should support cache field policies that filter key arguments", async () => { + const query = gql` + query books($order: string) { + books(skip: 0, limit: 2, order: $order) { + name } - `; - const transformedQuery = gql` - query books($order: string) { - books(skip: 0, limit: 2, order: $order) { - name - __typename - } + } + `; + const transformedQuery = gql` + query books($order: string) { + books(skip: 0, limit: 2, order: $order) { + name + __typename } - `; - - const result = { - books: [ - { - name: "abcd", - __typename: "Book", - }, - ], - }; + } + `; - const variables = { order: "popularity" }; + const result = { + books: [ + { + name: "abcd", + __typename: "Book", + }, + ], + }; - const link = mockSingleLink({ - request: { query: transformedQuery, variables }, - result: { data: result }, - }).setOnError(reject); + const variables = { order: "popularity" }; - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ - typePolicies: { - Query: { - fields: { - books: { - keyArgs: ["order"], - }, - }, - }, - }, - }), - }); + const link = mockSingleLink({ + request: { query: transformedQuery, variables }, + result: { data: result }, + }); - return client - .query({ query, variables }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); - }) - .then(resolve, reject); - } - ); - - itAsync( - "should broadcast changes for reactive variables", - async (resolve, reject) => { - const aVar = makeVar(123); - const bVar = makeVar("asdf"); - const cache: InMemoryCache = new InMemoryCache({ + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ typePolicies: { Query: { fields: { - a() { - return aVar(); - }, - b() { - return bVar(); + books: { + keyArgs: ["order"], }, }, }, }, - }); + }), + }); - const client = new ApolloClient({ cache }); + const actualResult = await client.query({ query, variables }); - const obsQueries = new Set>(); - const subs = new Set(); - function watch( - query: DocumentNode, - fetchPolicy: WatchQueryFetchPolicy = "cache-first" - ): any[] { - const results: any[] = []; - const obsQuery = client.watchQuery({ - query, - fetchPolicy, - }); - obsQueries.add(obsQuery); - subs.add( - obsQuery.subscribe({ - next(result) { - results.push(result.data); - }, - }) - ); - return results; - } + expect(actualResult.data).toEqual(result); + expect((client.cache as InMemoryCache).extract()).toMatchSnapshot(); + }); - const aResults = watch(gql` - { - a - } - `); - const bResults = watch(gql` - { - b - } - `); - const abResults = watch(gql` - { - a - b - } - `); + it("should broadcast changes for reactive variables", async () => { + const aVar = makeVar(123); + const bVar = makeVar("asdf"); + const cache: InMemoryCache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + a() { + return aVar(); + }, + b() { + return bVar(); + }, + }, + }, + }, + }); - await wait(); + const client = new ApolloClient({ cache }); - function checkLastResult( - results: any[], - expectedData: Record - ) { - const lastResult = results[results.length - 1]; - expect(lastResult).toEqual(expectedData); - return lastResult; - } - - checkLastResult(aResults, { a: 123 }); - const bAsdf = checkLastResult(bResults, { b: "asdf" }); - checkLastResult(abResults, { a: 123, b: "asdf" }); - - aVar(aVar() + 111); - await wait(); - - const a234 = checkLastResult(aResults, { a: 234 }); - expect(checkLastResult(bResults, { b: "asdf" })).toBe(bAsdf); - checkLastResult(abResults, { a: 234, b: "asdf" }); - - bVar(bVar().toUpperCase()); - await wait(); - - expect(checkLastResult(aResults, { a: 234 })).toBe(a234); - checkLastResult(bResults, { b: "ASDF" }); - checkLastResult(abResults, { a: 234, b: "ASDF" }); - - aVar(aVar() + 222); - bVar("oyez"); - await wait(); - - const a456 = checkLastResult(aResults, { a: 456 }); - const bOyez = checkLastResult(bResults, { b: "oyez" }); - const a456bOyez = checkLastResult(abResults, { a: 456, b: "oyez" }); - - // Since the ObservableQuery skips results that are the same as the - // previous result, and nothing is actually changing about the - // ROOT_QUERY.a field, clear previous results to give the invalidated - // results a chance to be delivered. - obsQueries.forEach((obsQuery) => obsQuery.resetLastResults()); - await wait(); - // Verify that resetting previous results did not trigger the delivery - // of any new results, by itself. - expect(checkLastResult(aResults, a456)).toBe(a456); - expect(checkLastResult(bResults, bOyez)).toBe(bOyez); - expect(checkLastResult(abResults, a456bOyez)).toBe(a456bOyez); - - // Now invalidate the ROOT_QUERY.a field. - client.cache.evict({ fieldName: "a" }); - await wait(); - - expect(checkLastResult(aResults, a456)).toBe(a456); - expect(checkLastResult(bResults, bOyez)).toBe(bOyez); - expect(checkLastResult(abResults, a456bOyez)).toBe(a456bOyez); - - const cQuery = gql` - { - c - } - `; - // Passing cache-only as the fetchPolicy allows the { c: "see" } - // result to be delivered even though networkStatus is still loading. - const cResults = watch(cQuery, "cache-only"); - - // Now try writing directly to the cache, rather than calling - // client.writeQuery. - client.cache.writeQuery({ - query: cQuery, - data: { - c: "see", - }, + const obsQueries = new Set>(); + const subs = new Set(); + function watch( + query: DocumentNode, + fetchPolicy: WatchQueryFetchPolicy = "cache-first" + ): any[] { + const results: any[] = []; + const obsQuery = client.watchQuery({ + query, + fetchPolicy, }); - await wait(); - - checkLastResult(aResults, a456); - checkLastResult(bResults, bOyez); - checkLastResult(abResults, a456bOyez); - checkLastResult(cResults, { c: "see" }); - - cache.modify({ - fields: { - c(value) { - expect(value).toBe("see"); - return "saw"; + obsQueries.add(obsQuery); + subs.add( + obsQuery.subscribe({ + next(result) { + results.push(result.data); }, - }, - }); - await wait(); + }) + ); + return results; + } - checkLastResult(aResults, a456); - checkLastResult(bResults, bOyez); - checkLastResult(abResults, a456bOyez); - checkLastResult(cResults, { c: "saw" }); + const aResults = watch(gql` + { + a + } + `); + const bResults = watch(gql` + { + b + } + `); + const abResults = watch(gql` + { + a + b + } + `); - client.cache.evict({ fieldName: "c" }); - await wait(); + await wait(); - checkLastResult(aResults, a456); - checkLastResult(bResults, bOyez); - checkLastResult(abResults, a456bOyez); - expect(checkLastResult(cResults, {})); + function checkLastResult( + results: any[], + expectedData: Record + ) { + const lastResult = results[results.length - 1]; + expect(lastResult).toEqual(expectedData); + return lastResult; + } - expect(aResults).toEqual([{ a: 123 }, { a: 234 }, { a: 456 }]); + checkLastResult(aResults, { a: 123 }); + const bAsdf = checkLastResult(bResults, { b: "asdf" }); + checkLastResult(abResults, { a: 123, b: "asdf" }); + + aVar(aVar() + 111); + await wait(); + + const a234 = checkLastResult(aResults, { a: 234 }); + expect(checkLastResult(bResults, { b: "asdf" })).toBe(bAsdf); + checkLastResult(abResults, { a: 234, b: "asdf" }); + + bVar(bVar().toUpperCase()); + await wait(); + + expect(checkLastResult(aResults, { a: 234 })).toBe(a234); + checkLastResult(bResults, { b: "ASDF" }); + checkLastResult(abResults, { a: 234, b: "ASDF" }); + + aVar(aVar() + 222); + bVar("oyez"); + await wait(); + + const a456 = checkLastResult(aResults, { a: 456 }); + const bOyez = checkLastResult(bResults, { b: "oyez" }); + const a456bOyez = checkLastResult(abResults, { a: 456, b: "oyez" }); + + // Since the ObservableQuery skips results that are the same as the + // previous result, and nothing is actually changing about the + // ROOT_QUERY.a field, clear previous results to give the invalidated + // results a chance to be delivered. + obsQueries.forEach((obsQuery) => obsQuery.resetLastResults()); + await wait(); + // Verify that resetting previous results did not trigger the delivery + // of any new results, by itself. + expect(checkLastResult(aResults, a456)).toBe(a456); + expect(checkLastResult(bResults, bOyez)).toBe(bOyez); + expect(checkLastResult(abResults, a456bOyez)).toBe(a456bOyez); + + // Now invalidate the ROOT_QUERY.a field. + client.cache.evict({ fieldName: "a" }); + await wait(); + + expect(checkLastResult(aResults, a456)).toBe(a456); + expect(checkLastResult(bResults, bOyez)).toBe(bOyez); + expect(checkLastResult(abResults, a456bOyez)).toBe(a456bOyez); + + const cQuery = gql` + { + c + } + `; + // Passing cache-only as the fetchPolicy allows the { c: "see" } + // result to be delivered even though networkStatus is still loading. + const cResults = watch(cQuery, "cache-only"); + + // Now try writing directly to the cache, rather than calling + // client.writeQuery. + client.cache.writeQuery({ + query: cQuery, + data: { + c: "see", + }, + }); + await wait(); - expect(bResults).toEqual([{ b: "asdf" }, { b: "ASDF" }, { b: "oyez" }]); + checkLastResult(aResults, a456); + checkLastResult(bResults, bOyez); + checkLastResult(abResults, a456bOyez); + checkLastResult(cResults, { c: "see" }); - expect(abResults).toEqual([ - { a: 123, b: "asdf" }, - { a: 234, b: "asdf" }, - { a: 234, b: "ASDF" }, - { a: 456, b: "oyez" }, - ]); + cache.modify({ + fields: { + c(value) { + expect(value).toBe("see"); + return "saw"; + }, + }, + }); + await wait(); - expect(cResults).toEqual([{}, { c: "see" }, { c: "saw" }, {}]); + checkLastResult(aResults, a456); + checkLastResult(bResults, bOyez); + checkLastResult(abResults, a456bOyez); + checkLastResult(cResults, { c: "saw" }); - subs.forEach((sub) => sub.unsubscribe()); + client.cache.evict({ fieldName: "c" }); + await wait(); - resolve(); - } - ); + checkLastResult(aResults, a456); + checkLastResult(bResults, bOyez); + checkLastResult(abResults, a456bOyez); + expect(checkLastResult(cResults, {})); + + expect(aResults).toEqual([{ a: 123 }, { a: 234 }, { a: 456 }]); + + expect(bResults).toEqual([{ b: "asdf" }, { b: "ASDF" }, { b: "oyez" }]); + + expect(abResults).toEqual([ + { a: 123, b: "asdf" }, + { a: 234, b: "asdf" }, + { a: 234, b: "ASDF" }, + { a: 456, b: "oyez" }, + ]); + + expect(cResults).toEqual([{}, { c: "see" }, { c: "saw" }, {}]); + + subs.forEach((sub) => sub.unsubscribe()); + }); function wait(time = 10) { return new Promise((resolve) => setTimeout(resolve, time)); } - itAsync( - "should call forgetCache for reactive vars when stopped", - async (resolve, reject) => { - const aVar = makeVar(123); - const bVar = makeVar("asdf"); - const aSpy = jest.spyOn(aVar, "forgetCache"); - const bSpy = jest.spyOn(bVar, "forgetCache"); - const cache: InMemoryCache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - a() { - return aVar(); - }, - b() { - return bVar(); - }, + it("should call forgetCache for reactive vars when stopped", async () => { + const aVar = makeVar(123); + const bVar = makeVar("asdf"); + const aSpy = jest.spyOn(aVar, "forgetCache"); + const bSpy = jest.spyOn(bVar, "forgetCache"); + const cache: InMemoryCache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + a() { + return aVar(); + }, + b() { + return bVar(); }, }, }, - }); - - const client = new ApolloClient({ cache }); + }, + }); - const obsQueries = new Set>(); - const subs = new Set(); - function watch( - query: DocumentNode, - fetchPolicy: WatchQueryFetchPolicy = "cache-first" - ): any[] { - const results: any[] = []; - const obsQuery = client.watchQuery({ - query, - fetchPolicy, - }); - obsQueries.add(obsQuery); - subs.add( - obsQuery.subscribe({ - next(result) { - results.push(result.data); - }, - }) - ); - return results; - } + const client = new ApolloClient({ cache }); - const aQuery = gql` - { - a - } - `; - const bQuery = gql` - { - b - } - `; - const abQuery = gql` - { - a - b - } - `; + const obsQueries = new Set>(); + const subs = new Set(); + function watch( + query: DocumentNode, + fetchPolicy: WatchQueryFetchPolicy = "cache-first" + ): any[] { + const results: any[] = []; + const obsQuery = client.watchQuery({ + query, + fetchPolicy, + }); + obsQueries.add(obsQuery); + subs.add( + obsQuery.subscribe({ + next(result) { + results.push(result.data); + }, + }) + ); + return results; + } - const aResults = watch(aQuery); - const bResults = watch(bQuery); + const aQuery = gql` + { + a + } + `; + const bQuery = gql` + { + b + } + `; + const abQuery = gql` + { + a + b + } + `; - expect(cache["watches"].size).toBe(2); + const aResults = watch(aQuery); + const bResults = watch(bQuery); - expect(aResults).toEqual([]); - expect(bResults).toEqual([]); + expect(cache["watches"].size).toBe(2); - expect(aSpy).not.toBeCalled(); - expect(bSpy).not.toBeCalled(); + expect(aResults).toEqual([]); + expect(bResults).toEqual([]); - subs.forEach((sub) => sub.unsubscribe()); + expect(aSpy).not.toBeCalled(); + expect(bSpy).not.toBeCalled(); - expect(aSpy).toBeCalledTimes(1); - expect(aSpy).toBeCalledWith(cache); - expect(bSpy).toBeCalledTimes(1); - expect(bSpy).toBeCalledWith(cache); + subs.forEach((sub) => sub.unsubscribe()); - expect(aResults).toEqual([]); - expect(bResults).toEqual([]); + expect(aSpy).toBeCalledTimes(1); + expect(aSpy).toBeCalledWith(cache); + expect(bSpy).toBeCalledTimes(1); + expect(bSpy).toBeCalledWith(cache); - expect(cache["watches"].size).toBe(0); - const abResults = watch(abQuery); - expect(abResults).toEqual([]); - expect(cache["watches"].size).toBe(1); + expect(aResults).toEqual([]); + expect(bResults).toEqual([]); - await wait(); + expect(cache["watches"].size).toBe(0); + const abResults = watch(abQuery); + expect(abResults).toEqual([]); + expect(cache["watches"].size).toBe(1); - expect(aResults).toEqual([]); - expect(bResults).toEqual([]); - expect(abResults).toEqual([{ a: 123, b: "asdf" }]); + await wait(); - client.stop(); + expect(aResults).toEqual([]); + expect(bResults).toEqual([]); + expect(abResults).toEqual([{ a: 123, b: "asdf" }]); - await wait(); + client.stop(); - expect(aSpy).toBeCalledTimes(2); - expect(aSpy).toBeCalledWith(cache); - expect(bSpy).toBeCalledTimes(2); - expect(bSpy).toBeCalledWith(cache); + await wait(); - resolve(); - } - ); + expect(aSpy).toBeCalledTimes(2); + expect(aSpy).toBeCalledWith(cache); + expect(bSpy).toBeCalledTimes(2); + expect(bSpy).toBeCalledWith(cache); + }); describe("default settings", () => { const query = gql` @@ -3777,12 +3482,12 @@ describe("@connection", () => { await expect(stream).not.toEmitAnything(); }); - itAsync("allows setting default options for query", (resolve, reject) => { + it("allows setting default options for query", async () => { const errors = [{ message: "failure", name: "failure" }]; const link = mockSingleLink({ request: { query }, result: { errors }, - }).setOnError(reject); + }); const client = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }), @@ -3791,55 +3496,46 @@ describe("@connection", () => { }, }); - return client - .query({ query }) - .then((result) => { - expect(result.errors).toEqual(errors); - }) - .then(resolve, reject); + const result = await client.query({ query }); + + expect(result.errors).toEqual(errors); }); - itAsync( - "allows setting default options for mutation", - (resolve, reject) => { - const mutation = gql` - mutation upVote($id: ID!) { - upvote(id: $id) { - success - } + it("allows setting default options for mutation", async () => { + const mutation = gql` + mutation upVote($id: ID!) { + upvote(id: $id) { + success } - `; - - const data = { - upvote: { success: true }, - }; - - const link = mockSingleLink({ - request: { query: mutation, variables: { id: 1 } }, - result: { data }, - }).setOnError(reject); - - const client = new ApolloClient({ - link, - cache: new InMemoryCache({ addTypename: false }), - defaultOptions: { - mutate: { variables: { id: 1 } }, - }, - }); + } + `; - return client - .mutate({ - mutation, - // This undefined value should be ignored in favor of - // defaultOptions.mutate.variables. - variables: void 0, - }) - .then((result) => { - expect(result.data).toEqual(data); - }) - .then(resolve, reject); - } - ); + const data = { + upvote: { success: true }, + }; + + const link = mockSingleLink({ + request: { query: mutation, variables: { id: 1 } }, + result: { data }, + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + defaultOptions: { + mutate: { variables: { id: 1 } }, + }, + }); + + const result = await client.mutate({ + mutation, + // This undefined value should be ignored in favor of + // defaultOptions.mutate.variables. + variables: void 0, + }); + + expect(result.data).toEqual(data); + }); }); }); @@ -6351,8 +6047,6 @@ describe("custom document transforms", () => { }); function clientRoundtrip( - resolve: (result: any) => any, - reject: (reason: any) => any, query: DocumentNode, data: FormattedExecutionResult, variables?: any, @@ -6361,7 +6055,7 @@ function clientRoundtrip( const link = mockSingleLink({ request: { query: cloneDeep(query) }, result: data, - }).setOnError(reject); + }); const client = new ApolloClient({ link, @@ -6370,10 +6064,7 @@ function clientRoundtrip( }), }); - return client - .query({ query, variables }) - .then((result) => { - expect(result.data).toEqual(data.data); - }) - .then(resolve, reject); + return client.query({ query, variables }).then((result) => { + expect(result.data).toEqual(data.data); + }); } diff --git a/src/__tests__/local-state/general.ts b/src/__tests__/local-state/general.ts index 0d65993e821..c1f570e85ac 100644 --- a/src/__tests__/local-state/general.ts +++ b/src/__tests__/local-state/general.ts @@ -17,8 +17,7 @@ import { ApolloLink } from "../../link/core"; import { Operation } from "../../link/core"; import { ApolloClient } from "../../core"; import { ApolloCache, InMemoryCache } from "../../cache"; -import { itAsync } from "../../testing"; -import { spyOnConsole } from "../../testing/internal"; +import { ObservableStream, spyOnConsole } from "../../testing/internal"; describe("General functionality", () => { it("should not impact normal non-@client use", () => { @@ -279,57 +278,43 @@ describe("Cache manipulation", () => { }); }); - itAsync( - "should be able to write to the cache with a local mutation and have " + - "things rerender automatically", - (resolve, reject) => { - const query = gql` - { - field @client - } - `; + it("should be able to write to the cache with a local mutation and have things rerender automatically", async () => { + const query = gql` + { + field @client + } + `; - const mutation = gql` - mutation start { - start @client - } - `; + const mutation = gql` + mutation start { + start @client + } + `; - const resolvers = { - Query: { - field: () => 0, - }, - Mutation: { - start: (_1: any, _2: any, { cache }: { cache: InMemoryCache }) => { - cache.writeQuery({ query, data: { field: 1 } }); - return { start: true }; - }, + const resolvers = { + Query: { + field: () => 0, + }, + Mutation: { + start: (_1: any, _2: any, { cache }: { cache: InMemoryCache }) => { + cache.writeQuery({ query, data: { field: 1 } }); + return { start: true }; }, - }; + }, + }; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: ApolloLink.empty(), - resolvers, - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + resolvers, + }); - let count = 0; - client.watchQuery({ query }).subscribe({ - next: ({ data }) => { - count++; - if (count === 1) { - expect({ ...data }).toMatchObject({ field: 0 }); - client.mutate({ mutation }); - } + const stream = new ObservableStream(client.watchQuery({ query })); - if (count === 2) { - expect({ ...data }).toMatchObject({ field: 1 }); - resolve(); - } - }, - }); - } - ); + await expect(stream).toEmitMatchedValue({ data: { field: 0 } }); + await client.mutate({ mutation }); + await expect(stream).toEmitMatchedValue({ data: { field: 1 } }); + }); it("should support writing to the cache with a local mutation using variables", () => { const query = gql` @@ -381,376 +366,352 @@ describe("Cache manipulation", () => { }); }); - itAsync( - "should read @client fields from cache on refetch (#4741)", - (resolve, reject) => { - const query = gql` - query FetchInitialData { - serverData { - id - title - } - selectedItemId @client + it("should read @client fields from cache on refetch (#4741)", async () => { + const query = gql` + query FetchInitialData { + serverData { + id + title } - `; + selectedItemId @client + } + `; - const mutation = gql` - mutation Select { - select(itemId: $id) @client - } - `; + const mutation = gql` + mutation Select { + select(itemId: $id) @client + } + `; - const serverData = { - __typename: "ServerData", - id: 123, - title: "Oyez and Onoz", - }; + const serverData = { + __typename: "ServerData", + id: 123, + title: "Oyez and Onoz", + }; - let selectedItemId = -1; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: new ApolloLink(() => Observable.of({ data: { serverData } })), - resolvers: { - Query: { - selectedItemId() { - return selectedItemId; - }, + let selectedItemId = -1; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => Observable.of({ data: { serverData } })), + resolvers: { + Query: { + selectedItemId() { + return selectedItemId; }, - Mutation: { - select(_, { itemId }) { - selectedItemId = itemId; - }, + }, + Mutation: { + select(_, { itemId }) { + selectedItemId = itemId; }, }, - }); + }, + }); - client.watchQuery({ query }).subscribe({ - next(result) { - expect(result).toEqual({ - data: { - serverData, - selectedItemId, - }, - loading: false, - networkStatus: 7, - }); + const stream = new ObservableStream(client.watchQuery({ query })); - if (selectedItemId !== 123) { - client.mutate({ - mutation, - variables: { - id: 123, - }, - refetchQueries: ["FetchInitialData"], - }); - } else { - resolve(); - } - }, - }); - } - ); + await expect(stream).toEmitValue({ + data: { + serverData, + selectedItemId: -1, + }, + loading: false, + networkStatus: 7, + }); - itAsync( - "should rerun @client(always: true) fields on entity update", - (resolve, reject) => { - const query = gql` - query GetClientData($id: ID) { - clientEntity(id: $id) @client(always: true) { - id - title - titleLength @client(always: true) - } - } - `; + await client.mutate({ + mutation, + variables: { id: 123 }, + refetchQueries: ["FetchInitialData"], + }); - const mutation = gql` - mutation AddOrUpdate { - addOrUpdate(id: $id, title: $title) @client - } - `; + await expect(stream).toEmitValue({ + data: { + serverData, + selectedItemId: 123, + }, + loading: false, + networkStatus: 7, + }); + }); - const fragment = gql` - fragment ClientDataFragment on ClientData { + it("should rerun @client(always: true) fields on entity update", async () => { + const query = gql` + query GetClientData($id: ID) { + clientEntity(id: $id) @client(always: true) { id title + titleLength @client(always: true) } - `; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: new ApolloLink(() => Observable.of({ data: {} })), - resolvers: { - ClientData: { - titleLength(data) { - return data.title.length; - }, + } + `; + + const mutation = gql` + mutation AddOrUpdate { + addOrUpdate(id: $id, title: $title) @client + } + `; + + const fragment = gql` + fragment ClientDataFragment on ClientData { + id + title + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new ApolloLink(() => Observable.of({ data: {} })), + resolvers: { + ClientData: { + titleLength(data) { + return data.title.length; }, - Query: { - clientEntity(_root, { id }, { cache }) { - return cache.readFragment({ - id: cache.identify({ id, __typename: "ClientData" }), - fragment, - }); - }, + }, + Query: { + clientEntity(_root, { id }, { cache }) { + return cache.readFragment({ + id: cache.identify({ id, __typename: "ClientData" }), + fragment, + }); }, - Mutation: { - addOrUpdate(_root, { id, title }, { cache }) { - return cache.writeFragment({ - id: cache.identify({ id, __typename: "ClientData" }), - fragment, - data: { id, title, __typename: "ClientData" }, - }); - }, + }, + Mutation: { + addOrUpdate(_root, { id, title }, { cache }) { + return cache.writeFragment({ + id: cache.identify({ id, __typename: "ClientData" }), + fragment, + data: { id, title, __typename: "ClientData" }, + }); }, }, - }); + }, + }); - const entityId = 1; - const shortTitle = "Short"; - const longerTitle = "A little longer"; - client.mutate({ - mutation, - variables: { - id: entityId, - title: shortTitle, - }, + const entityId = 1; + const shortTitle = "Short"; + const longerTitle = "A little longer"; + await client.mutate({ + mutation, + variables: { + id: entityId, + title: shortTitle, + }, + }); + const stream = new ObservableStream( + client.watchQuery({ query, variables: { id: entityId } }) + ); + + { + const result = await stream.takeNext(); + + expect(result.data.clientEntity).toEqual({ + id: entityId, + title: shortTitle, + titleLength: shortTitle.length, + __typename: "ClientData", }); - let mutated = false; - client.watchQuery({ query, variables: { id: entityId } }).subscribe({ - next(result) { - if (!mutated) { - expect(result.data.clientEntity).toEqual({ - id: entityId, - title: shortTitle, - titleLength: shortTitle.length, - __typename: "ClientData", - }); - client.mutate({ - mutation, - variables: { - id: entityId, - title: longerTitle, - }, - }); - mutated = true; - } else if (mutated) { - expect(result.data.clientEntity).toEqual({ - id: entityId, - title: longerTitle, - titleLength: longerTitle.length, - __typename: "ClientData", - }); - resolve(); - } - }, + } + + await client.mutate({ + mutation, + variables: { + id: entityId, + title: longerTitle, + }, + }); + + { + const result = await stream.takeNext(); + + expect(result.data.clientEntity).toEqual({ + id: entityId, + title: longerTitle, + titleLength: longerTitle.length, + __typename: "ClientData", }); } - ); + + await expect(stream).not.toEmitAnything(); + }); }); describe("Sample apps", () => { - itAsync( - "should support a simple counter app using local state", - (resolve, reject) => { - const query = gql` - query GetCount { - count @client - lastCount # stored in db on server - } - `; + it("should support a simple counter app using local state", async () => { + const query = gql` + query GetCount { + count @client + lastCount # stored in db on server + } + `; - const increment = gql` - mutation Increment($amount: Int = 1) { - increment(amount: $amount) @client - } - `; + const increment = gql` + mutation Increment($amount: Int = 1) { + increment(amount: $amount) @client + } + `; - const decrement = gql` - mutation Decrement($amount: Int = 1) { - decrement(amount: $amount) @client - } - `; + const decrement = gql` + mutation Decrement($amount: Int = 1) { + decrement(amount: $amount) @client + } + `; - const link = new ApolloLink((operation) => { - expect(operation.operationName).toBe("GetCount"); - return Observable.of({ data: { lastCount: 1 } }); - }); + const link = new ApolloLink((operation) => { + expect(operation.operationName).toBe("GetCount"); + return Observable.of({ data: { lastCount: 1 } }); + }); - const client = new ApolloClient({ - link, - cache: new InMemoryCache(), - resolvers: {}, - }); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + resolvers: {}, + }); - const update = ( - query: DocumentNode, - updater: (data: { count: number }, variables: { amount: number }) => any - ) => { - return ( - _result: {}, - variables: { amount: number }, - { cache }: { cache: ApolloCache } - ): null => { - const read = client.readQuery<{ count: number }>({ - query, - variables, - }); - if (read) { - const data = updater(read, variables); - cache.writeQuery({ query, variables, data }); - } else { - throw new Error("readQuery returned a falsy value"); - } - return null; - }; + const update = ( + query: DocumentNode, + updater: (data: { count: number }, variables: { amount: number }) => any + ) => { + return ( + _result: {}, + variables: { amount: number }, + { cache }: { cache: ApolloCache } + ): null => { + const read = client.readQuery<{ count: number }>({ + query, + variables, + }); + if (read) { + const data = updater(read, variables); + cache.writeQuery({ query, variables, data }); + } else { + throw new Error("readQuery returned a falsy value"); + } + return null; }; + }; - const resolvers = { - Query: { - count: () => 0, - }, - Mutation: { - increment: update(query, ({ count, ...rest }, { amount }) => ({ - ...rest, - count: count + amount, - })), - decrement: update(query, ({ count, ...rest }, { amount }) => ({ - ...rest, - count: count - amount, - })), - }, - }; + const resolvers = { + Query: { + count: () => 0, + }, + Mutation: { + increment: update(query, ({ count, ...rest }, { amount }) => ({ + ...rest, + count: count + amount, + })), + decrement: update(query, ({ count, ...rest }, { amount }) => ({ + ...rest, + count: count - amount, + })), + }, + }; - client.addResolvers(resolvers); - - let count = 0; - client.watchQuery({ query }).subscribe({ - next: ({ data }) => { - count++; - if (count === 1) { - try { - expect({ ...data }).toMatchObject({ count: 0, lastCount: 1 }); - } catch (e) { - reject(e); - } - client.mutate({ mutation: increment, variables: { amount: 2 } }); - } + client.addResolvers(resolvers); + const stream = new ObservableStream(client.watchQuery({ query })); - if (count === 2) { - try { - expect({ ...data }).toMatchObject({ count: 2, lastCount: 1 }); - } catch (e) { - reject(e); - } - client.mutate({ mutation: decrement, variables: { amount: 1 } }); - } - if (count === 3) { - try { - expect({ ...data }).toMatchObject({ count: 1, lastCount: 1 }); - } catch (e) { - reject(e); - } - resolve(); - } - }, - error: (e) => reject(e), - complete: reject, - }); - } - ); + await expect(stream).toEmitMatchedValue({ + data: { count: 0, lastCount: 1 }, + }); - itAsync( - "should support a simple todo app using local state", - (resolve, reject) => { - const query = gql` - query GetTasks { - todos @client { - message - title - } - } - `; + await client.mutate({ mutation: increment, variables: { amount: 2 } }); - const mutation = gql` - mutation AddTodo($message: String, $title: String) { - addTodo(message: $message, title: $title) @client - } - `; + await expect(stream).toEmitMatchedValue({ + data: { count: 2, lastCount: 1 }, + }); - const client = new ApolloClient({ - link: ApolloLink.empty(), - cache: new InMemoryCache(), - resolvers: {}, - }); + await client.mutate({ mutation: decrement, variables: { amount: 1 } }); + + await expect(stream).toEmitMatchedValue({ + data: { count: 1, lastCount: 1 }, + }); + }); - interface Todo { - title: string; - message: string; - __typename: string; + it("should support a simple todo app using local state", async () => { + const query = gql` + query GetTasks { + todos @client { + message + title + } } + `; - const update = ( - query: DocumentNode, - updater: (todos: any, variables: Todo) => any - ) => { - return ( - _result: {}, - variables: Todo, - { cache }: { cache: ApolloCache } - ): null => { - const data = updater( - client.readQuery({ query, variables }), - variables - ); - cache.writeQuery({ query, variables, data }); - return null; - }; - }; + const mutation = gql` + mutation AddTodo($message: String, $title: String) { + addTodo(message: $message, title: $title) @client + } + `; - const resolvers = { - Query: { - todos: () => [], - }, - Mutation: { - addTodo: update(query, ({ todos }, { title, message }: Todo) => ({ - todos: todos.concat([{ message, title, __typename: "Todo" }]), - })), - }, + const client = new ApolloClient({ + link: ApolloLink.empty(), + cache: new InMemoryCache(), + resolvers: {}, + }); + + interface Todo { + title: string; + message: string; + __typename: string; + } + + const update = ( + query: DocumentNode, + updater: (todos: any, variables: Todo) => any + ) => { + return ( + _result: {}, + variables: Todo, + { cache }: { cache: ApolloCache } + ): null => { + const data = updater(client.readQuery({ query, variables }), variables); + cache.writeQuery({ query, variables, data }); + return null; }; + }; - client.addResolvers(resolvers); - - let count = 0; - client.watchQuery({ query }).subscribe({ - next: ({ data }: any) => { - count++; - if (count === 1) { - expect({ ...data }).toMatchObject({ todos: [] }); - client.mutate({ - mutation, - variables: { - title: "Apollo Client 2.0", - message: "ship it", - }, - }); - } else if (count === 2) { - expect(data.todos.map((x: Todo) => ({ ...x }))).toMatchObject([ - { - title: "Apollo Client 2.0", - message: "ship it", - __typename: "Todo", - }, - ]); - resolve(); - } + const resolvers = { + Query: { + todos: () => [], + }, + Mutation: { + addTodo: update(query, ({ todos }, { title, message }: Todo) => ({ + todos: todos.concat([{ message, title, __typename: "Todo" }]), + })), + }, + }; + + client.addResolvers(resolvers); + const stream = new ObservableStream(client.watchQuery({ query })); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ todos: [] }); + } + + await client.mutate({ + mutation, + variables: { + title: "Apollo Client 2.0", + message: "ship it", + }, + }); + + { + const { data } = await stream.takeNext(); + + expect(data.todos).toEqual([ + { + title: "Apollo Client 2.0", + message: "ship it", + __typename: "Todo", }, - }); + ]); } - ); + }); }); describe("Combining client and server state/operations", () => { - itAsync("should merge remote and local state", (resolve, reject) => { + it("should merge remote and local state", async () => { const query = gql` query list { list(name: "my list") { @@ -815,462 +776,403 @@ describe("Combining client and server state/operations", () => { const observer = client.watchQuery({ query }); - let count = 0; - observer.subscribe({ - next: (response) => { - if (count === 0) { - const initial = { ...data }; - initial.list.items = initial.list.items.map((x) => ({ - ...x, - isSelected: false, - })); - expect(response.data).toMatchObject(initial); - } - if (count === 1) { - expect((response.data as any).list.items[0].isSelected).toBe(true); - expect((response.data as any).list.items[1].isSelected).toBe(false); - resolve(); + const stream = new ObservableStream(observer); + + { + const response = await stream.takeNext(); + const initial = { ...data }; + initial.list.items = initial.list.items.map((x) => ({ + ...x, + isSelected: false, + })); + + expect(response.data).toMatchObject(initial); + } + + await client.mutate({ + mutation: gql` + mutation SelectItem($id: Int!) { + toggleItem(id: $id) @client } - count++; - }, - error: reject, + `, + variables: { id: 1 }, }); - const variables = { id: 1 }; - const mutation = gql` - mutation SelectItem($id: Int!) { - toggleItem(id: $id) @client - } - `; - // After initial result, toggle the state of one of the items - setTimeout(() => { - client.mutate({ mutation, variables }); - }, 10); + + { + const response = await stream.takeNext(); + + expect((response.data as any).list.items[0].isSelected).toBe(true); + expect((response.data as any).list.items[1].isSelected).toBe(false); + } }); - itAsync( - "query resolves with loading: false if subsequent responses contain the same data", - (resolve, reject) => { - const request = { - query: gql` - query people($id: Int) { - people(id: $id) { - id - name - } + it("query resolves with loading: false if subsequent responses contain the same data", async () => { + const request = { + query: gql` + query people($id: Int) { + people(id: $id) { + id + name } - `, - variables: { - id: 1, - }, - notifyOnNetworkStatusChange: true, - }; + } + `, + variables: { + id: 1, + }, + notifyOnNetworkStatusChange: true, + }; - const PersonType = new GraphQLObjectType({ - name: "Person", - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - }, - }); + const PersonType = new GraphQLObjectType({ + name: "Person", + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + }); - const peopleData = [ - { id: 1, name: "John Smith" }, - { id: 2, name: "Sara Smith" }, - { id: 3, name: "Budd Deey" }, - ]; - - const QueryType = new GraphQLObjectType({ - name: "Query", - fields: { - people: { - type: PersonType, - args: { - id: { - type: GraphQLInt, - }, - }, - resolve: (_, { id }) => { - return peopleData; + const peopleData = [ + { id: 1, name: "John Smith" }, + { id: 2, name: "Sara Smith" }, + { id: 3, name: "Budd Deey" }, + ]; + + const QueryType = new GraphQLObjectType({ + name: "Query", + fields: { + people: { + type: PersonType, + args: { + id: { + type: GraphQLInt, }, }, + resolve: (_, { id }) => { + return peopleData; + }, }, - }); + }, + }); - const schema = new GraphQLSchema({ query: QueryType }); - - const link = new ApolloLink((operation) => { - // @ts-ignore - return new Observable(async (observer) => { - const { query, operationName, variables } = operation; - try { - const result = await graphql({ - schema, - source: print(query), - variableValues: variables, - operationName, - }); - observer.next(result); - observer.complete(); - } catch (err) { - observer.error(err); - } - }); + const schema = new GraphQLSchema({ query: QueryType }); + + const link = new ApolloLink((operation) => { + // @ts-ignore + return new Observable(async (observer) => { + const { query, operationName, variables } = operation; + try { + const result = await graphql({ + schema, + source: print(query), + variableValues: variables, + operationName, + }); + observer.next(result); + observer.complete(); + } catch (err) { + observer.error(err); + } }); + }); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + }); - const observer = client.watchQuery(request); + const observable = client.watchQuery(request); + const stream = new ObservableStream(observable); - let count = 0; - observer.subscribe({ - next: ({ loading, data }) => { - if (count === 0) expect(loading).toBe(false); - if (count === 1) expect(loading).toBe(true); - if (count === 2) { - expect(loading).toBe(false); - resolve(); - } - count++; - }, - error: reject, - }); + await expect(stream).toEmitMatchedValue({ loading: false }); - setTimeout(() => { - observer.refetch({ - id: 2, - }); - }, 1); - } - ); + await observable.refetch({ id: 2 }); - itAsync( - "should correctly propagate an error from a client resolver", - async (resolve, reject) => { - const data = { - list: { - __typename: "List", - items: [ - { __typename: "ListItem", id: 1, name: "first", isDone: true }, - { __typename: "ListItem", id: 2, name: "second", isDone: false }, - ], - }, - }; + await expect(stream).toEmitMatchedValue({ loading: true }); + await expect(stream).toEmitMatchedValue({ loading: false }); + }); - const link = new ApolloLink(() => Observable.of({ data })); + it("should correctly propagate an error from a client resolver", async () => { + const data = { + list: { + __typename: "List", + items: [ + { __typename: "ListItem", id: 1, name: "first", isDone: true }, + { __typename: "ListItem", id: 2, name: "second", isDone: false }, + ], + }, + }; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Query: { - hasBeenIllegallyTouched: (_, _v, _c) => { - throw new Error("Illegal Query Operation Occurred"); - }, - }, + const link = new ApolloLink(() => Observable.of({ data })); - Mutation: { - touchIllegally: (_, _v, _c) => { - throw new Error("Illegal Mutation Operation Occurred"); - }, + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { + Query: { + hasBeenIllegallyTouched: (_, _v, _c) => { + throw new Error("Illegal Query Operation Occurred"); }, }, - }); - const variables = { id: 1 }; - const query = gql` - query hasBeenIllegallyTouched($id: Int!) { - hasBeenIllegallyTouched(id: $id) @client - } - `; - const mutation = gql` - mutation SelectItem($id: Int!) { - touchIllegally(id: $id) @client - } - `; + Mutation: { + touchIllegally: (_, _v, _c) => { + throw new Error("Illegal Mutation Operation Occurred"); + }, + }, + }, + }); - try { - await client.query({ query, variables }); - reject("Should have thrown!"); - } catch (e) { - // Test Passed! - expect(() => { - throw e; - }).toThrowErrorMatchingSnapshot(); + const variables = { id: 1 }; + const query = gql` + query hasBeenIllegallyTouched($id: Int!) { + hasBeenIllegallyTouched(id: $id) @client } - - try { - await client.mutate({ mutation, variables }); - reject("Should have thrown!"); - } catch (e) { - // Test Passed! - expect(() => { - throw e; - }).toThrowErrorMatchingSnapshot(); + `; + const mutation = gql` + mutation SelectItem($id: Int!) { + touchIllegally(id: $id) @client } + `; - resolve(); - } - ); + await expect( + client.query({ query, variables }) + ).rejects.toThrowErrorMatchingSnapshot(); + + await expect( + client.mutate({ mutation, variables }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); it("should handle a simple query with both server and client fields", async () => { using _consoleSpies = spyOnConsole.takeSnapshots("error"); - await new Promise((resolve, reject) => { - const query = gql` - query GetCount { - count @client - lastCount - } - `; - const cache = new InMemoryCache(); + const query = gql` + query GetCount { + count @client + lastCount + } + `; + const cache = new InMemoryCache(); - const link = new ApolloLink((operation) => { - expect(operation.operationName).toBe("GetCount"); - return Observable.of({ data: { lastCount: 1 } }); - }); + const link = new ApolloLink((operation) => { + expect(operation.operationName).toBe("GetCount"); + return Observable.of({ data: { lastCount: 1 } }); + }); - const client = new ApolloClient({ - cache, - link, - resolvers: {}, - }); + const client = new ApolloClient({ + cache, + link, + resolvers: {}, + }); - cache.writeQuery({ - query, - data: { - count: 0, - }, - }); + cache.writeQuery({ + query, + data: { + count: 0, + }, + }); - client.watchQuery({ query }).subscribe({ - next: ({ data }) => { - expect({ ...data }).toMatchObject({ count: 0, lastCount: 1 }); - resolve(); - }, - }); + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitMatchedValue({ + data: { count: 0, lastCount: 1 }, }); }); it("should support nested querying of both server and client fields", async () => { using _consoleSpies = spyOnConsole.takeSnapshots("error"); - await new Promise((resolve, reject) => { - const query = gql` - query GetUser { - user { - firstName @client - lastName - } + const query = gql` + query GetUser { + user { + firstName @client + lastName } - `; - - const cache = new InMemoryCache(); - const link = new ApolloLink((operation) => { - expect(operation.operationName).toBe("GetUser"); - return Observable.of({ - data: { - user: { - __typename: "User", - // We need an id (or a keyFields policy) because, if the User - // object is not identifiable, the call to cache.writeQuery - // below will simply replace the existing data rather than - // merging the new data with the existing data. - id: 123, - lastName: "Doe", - }, - }, - }); - }); - - const client = new ApolloClient({ - cache, - link, - resolvers: {}, - }); + } + `; - cache.writeQuery({ - query, + const cache = new InMemoryCache(); + const link = new ApolloLink((operation) => { + expect(operation.operationName).toBe("GetUser"); + return Observable.of({ data: { user: { __typename: "User", + // We need an id (or a keyFields policy) because, if the User + // object is not identifiable, the call to cache.writeQuery + // below will simply replace the existing data rather than + // merging the new data with the existing data. id: 123, - firstName: "John", + lastName: "Doe", }, }, }); + }); - client.watchQuery({ query }).subscribe({ - next({ data }: any) { - const { user } = data; - try { - expect(user).toMatchObject({ - firstName: "John", - lastName: "Doe", - __typename: "User", - }); - } catch (e) { - reject(e); - } - resolve(); + const client = new ApolloClient({ + cache, + link, + resolvers: {}, + }); + + cache.writeQuery({ + query, + data: { + user: { + __typename: "User", + id: 123, + firstName: "John", }, - }); + }, + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitMatchedValue({ + data: { + user: { + firstName: "John", + lastName: "Doe", + __typename: "User", + }, + }, }); }); - itAsync( - "should combine both server and client mutations", - (resolve, reject) => { - const query = gql` - query SampleQuery { - count @client - user { - firstName - } + it("should combine both server and client mutations", async () => { + const query = gql` + query SampleQuery { + count @client + user { + firstName } - `; + } + `; - const mutation = gql` - mutation SampleMutation { - incrementCount @client - updateUser(firstName: "Harry") { - firstName - } + const mutation = gql` + mutation SampleMutation { + incrementCount @client + updateUser(firstName: "Harry") { + firstName } - `; + } + `; - const counterQuery = gql` - { - count @client - } - `; + const counterQuery = gql` + { + count @client + } + `; - const userQuery = gql` - { - user { - firstName - } + const userQuery = gql` + { + user { + firstName } - `; + } + `; - let watchCount = 0; - const link = new ApolloLink((operation: Operation): Observable<{}> => { - if (operation.operationName === "SampleQuery") { - return Observable.of({ - data: { user: { __typename: "User", firstName: "John" } }, - }); - } - if (operation.operationName === "SampleMutation") { - return Observable.of({ - data: { updateUser: { __typename: "User", firstName: "Harry" } }, - }); - } + const link = new ApolloLink((operation: Operation): Observable<{}> => { + if (operation.operationName === "SampleQuery") { return Observable.of({ - errors: [new Error(`Unknown operation ${operation.operationName}`)], + data: { user: { __typename: "User", firstName: "John" } }, }); + } + if (operation.operationName === "SampleMutation") { + return Observable.of({ + data: { updateUser: { __typename: "User", firstName: "Harry" } }, + }); + } + return Observable.of({ + errors: [new Error(`Unknown operation ${operation.operationName}`)], }); + }); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - resolvers: { - Mutation: { - incrementCount: (_, __, { cache }) => { - const { count } = cache.readQuery({ query: counterQuery }); - const data = { count: count + 1 }; - cache.writeQuery({ - query: counterQuery, - data, - }); - return null; - }, + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link, + resolvers: { + Mutation: { + incrementCount: (_, __, { cache }) => { + const { count } = cache.readQuery({ query: counterQuery }); + const data = { count: count + 1 }; + cache.writeQuery({ + query: counterQuery, + data, + }); + return null; }, }, - }); + }, + }); - cache.writeQuery({ - query: counterQuery, - data: { - count: 0, - }, - }); + cache.writeQuery({ + query: counterQuery, + data: { + count: 0, + }, + }); - client.watchQuery({ query }).subscribe({ - next: ({ data }: any) => { - if (watchCount === 0) { - expect(data.count).toEqual(0); - expect({ ...data.user }).toMatchObject({ - __typename: "User", - firstName: "John", - }); - watchCount += 1; - client.mutate({ - mutation, - update(proxy, { data: { updateUser } }) { - proxy.writeQuery({ - query: userQuery, - data: { - user: { ...updateUser }, - }, - }); - }, - }); - } else { - expect(data.count).toEqual(1); - expect({ ...data.user }).toMatchObject({ - __typename: "User", - firstName: "Harry", - }); - resolve(); - } - }, - }); - } - ); + const stream = new ObservableStream(client.watchQuery({ query })); - itAsync( - "handles server errors when root data property is null", - (resolve, reject) => { - const query = gql` - query GetUser { - user { - firstName @client - lastName - } - } - `; + await expect(stream).toEmitMatchedValue({ + data: { + count: 0, + user: { __typename: "User", firstName: "John" }, + }, + }); - const cache = new InMemoryCache(); - const link = new ApolloLink((operation) => { - return Observable.of({ - data: null, - errors: [ - new GraphQLError("something went wrong", { - extensions: { - code: "INTERNAL_SERVER_ERROR", - }, - path: ["user"], - }), - ], + await client.mutate({ + mutation, + update(proxy, { data: { updateUser } }) { + proxy.writeQuery({ + query: userQuery, + data: { + user: { ...updateUser }, + }, }); - }); + }, + }); - const client = new ApolloClient({ - cache, - link, - resolvers: {}, - }); + await expect(stream).toEmitMatchedValue({ + data: { + count: 1, + user: { __typename: "User", firstName: "Harry" }, + }, + }); + }); - client.watchQuery({ query }).subscribe({ - error(error) { - expect(error.message).toEqual("something went wrong"); - resolve(); - }, - next() { - reject(); - }, + it("handles server errors when root data property is null", async () => { + const query = gql` + query GetUser { + user { + firstName @client + lastName + } + } + `; + + const cache = new InMemoryCache(); + const link = new ApolloLink((operation) => { + return Observable.of({ + data: null, + errors: [ + new GraphQLError("something went wrong", { + extensions: { + code: "INTERNAL_SERVER_ERROR", + }, + path: ["user"], + }), + ], }); - } - ); + }); + + const client = new ApolloClient({ + cache, + link, + resolvers: {}, + }); + + const stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitError("something went wrong"); + }); }); diff --git a/src/__tests__/local-state/resolvers.ts b/src/__tests__/local-state/resolvers.ts index b305a3ed7d8..b1941cd80c7 100644 --- a/src/__tests__/local-state/resolvers.ts +++ b/src/__tests__/local-state/resolvers.ts @@ -1,27 +1,19 @@ import gql from "graphql-tag"; import { DocumentNode, ExecutionResult } from "graphql"; -import { assign } from "lodash"; import { LocalState } from "../../core/LocalState"; -import { - ApolloClient, - ApolloQueryResult, - Resolvers, - WatchQueryOptions, -} from "../../core"; +import { ApolloClient, ApolloQueryResult, Resolvers } from "../../core"; import { InMemoryCache, isReference } from "../../cache"; -import { Observable, Observer } from "../../utilities"; +import { Observable } from "../../utilities"; import { ApolloLink } from "../../link/core"; -import { itAsync } from "../../testing"; import mockQueryManager from "../../testing/core/mocking/mockQueryManager"; -import wrap from "../../testing/core/wrap"; +import { ObservableStream } from "../../testing/internal"; // Helper method that sets up a mockQueryManager and then passes on the // results to an observer. -const assertWithObserver = ({ - reject, +const setupTestWithResolvers = ({ resolvers, query, serverQuery, @@ -30,10 +22,8 @@ const assertWithObserver = ({ serverResult, error, delay, - observer, }: { - reject: (reason: any) => any; - resolvers?: Resolvers; + resolvers: Resolvers; query: DocumentNode; serverQuery?: DocumentNode; variables?: object; @@ -41,7 +31,6 @@ const assertWithObserver = ({ error?: Error; serverResult?: ExecutionResult; delay?: number; - observer: Observer>; }) => { const queryManager = mockQueryManager({ request: { query: serverQuery || query, variables }, @@ -50,22 +39,15 @@ const assertWithObserver = ({ delay, }); - if (resolvers) { - queryManager.getLocalState().addResolvers(resolvers); - } - - const finalOptions = assign( - { query, variables }, - queryOptions - ) as WatchQueryOptions; - return queryManager.watchQuery(finalOptions).subscribe({ - next: wrap(reject, observer.next!), - error: observer.error, - }); + queryManager.getLocalState().addResolvers(resolvers); + + return new ObservableStream( + queryManager.watchQuery({ query, variables, ...queryOptions }) + ); }; describe("Basic resolver capabilities", () => { - itAsync("should run resolvers for @client queries", (resolve, reject) => { + it("should run resolvers for @client queries", async () => { const query = gql` query Test { foo @client { @@ -80,234 +62,183 @@ describe("Basic resolver capabilities", () => { }, }; - assertWithObserver({ - reject, + const stream = setupTestWithResolvers({ resolvers, query }); + + await expect(stream).toEmitMatchedValue({ data: { foo: { bar: true } } }); + }); + + it("should handle queries with a mix of @client and server fields", async () => { + const query = gql` + query Mixed { + foo @client { + bar + } + bar { + baz + } + } + `; + + const serverQuery = gql` + query Mixed { + bar { + baz + } + } + `; + + const resolvers = { + Query: { + foo: () => ({ bar: true }), + }, + }; + + const stream = setupTestWithResolvers({ resolvers, query, - observer: { - next({ data }) { - try { - expect(data).toEqual({ foo: { bar: true } }); - } catch (error) { - reject(error); - } - resolve(); - }, + serverQuery, + serverResult: { data: { bar: { baz: true } } }, + }); + + await expect(stream).toEmitMatchedValue({ + data: { + foo: { bar: true }, + bar: { baz: true }, }, }); }); - itAsync( - "should handle queries with a mix of @client and server fields", - (resolve, reject) => { - const query = gql` - query Mixed { - foo @client { - bar - } - bar { - baz - } - } - `; + it("should handle a mix of @client fields with fragments and server fields", async () => { + const query = gql` + fragment client on ClientData { + bar + __typename + } - const serverQuery = gql` - query Mixed { - bar { - baz - } + query Mixed { + foo @client { + ...client } - `; - - const resolvers = { - Query: { - foo: () => ({ bar: true }), - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - serverQuery, - serverResult: { data: { bar: { baz: true } } }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ foo: { bar: true }, bar: { baz: true } }); - } catch (error) { - reject(error); - } - resolve(); - }, - }, - }); - } - ); - - itAsync( - "should handle a mix of @client fields with fragments and server fields", - (resolve, reject) => { - const query = gql` - fragment client on ClientData { - bar - __typename + bar { + baz } + } + `; - query Mixed { - foo @client { - ...client - } - bar { - baz - } + const serverQuery = gql` + query Mixed { + bar { + baz } - `; + } + `; - const serverQuery = gql` - query Mixed { - bar { - baz - } - } - `; + const resolvers = { + Query: { + foo: () => ({ bar: true, __typename: "ClientData" }), + }, + }; - const resolvers = { - Query: { - foo: () => ({ bar: true, __typename: "ClientData" }), - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - serverQuery, - serverResult: { data: { bar: { baz: true, __typename: "Bar" } } }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ - foo: { bar: true, __typename: "ClientData" }, - bar: { baz: true }, - }); - } catch (error) { - reject(error); - } - resolve(); - }, - }, - }); - } - ); + const stream = setupTestWithResolvers({ + resolvers, + query, + serverQuery, + serverResult: { data: { bar: { baz: true, __typename: "Bar" } } }, + }); - itAsync( - "should handle @client fields inside fragments", - (resolve, reject) => { - const query = gql` - fragment Foo on Foo { - bar - ...Foo2 - } - fragment Foo2 on Foo { - __typename - baz @client + await expect(stream).toEmitMatchedValue({ + data: { + foo: { bar: true, __typename: "ClientData" }, + bar: { baz: true }, + }, + }); + }); + + it("should handle @client fields inside fragments", async () => { + const query = gql` + fragment Foo on Foo { + bar + ...Foo2 + } + fragment Foo2 on Foo { + __typename + baz @client + } + query Mixed { + foo { + ...Foo } - query Mixed { - foo { - ...Foo - } - bar { - baz - } + bar { + baz } - `; + } + `; - const serverQuery = gql` - fragment Foo on Foo { - bar + const serverQuery = gql` + fragment Foo on Foo { + bar + } + query Mixed { + foo { + ...Foo } - query Mixed { - foo { - ...Foo - } - bar { - baz - } + bar { + baz } - `; + } + `; - const resolvers = { - Foo: { - baz: () => false, - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - serverQuery, - serverResult: { - data: { foo: { bar: true, __typename: `Foo` }, bar: { baz: true } }, - }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ - foo: { bar: true, baz: false, __typename: "Foo" }, - bar: { baz: true }, - }); - } catch (error) { - reject(error); - } - resolve(); - }, - }, - }); - } - ); + const resolvers = { + Foo: { + baz: () => false, + }, + }; - itAsync( - "should have access to query variables when running @client resolvers", - (resolve, reject) => { - const query = gql` - query WithVariables($id: ID!) { - foo @client { - bar(id: $id) - } + const stream = setupTestWithResolvers({ + resolvers, + query, + serverQuery, + serverResult: { + data: { foo: { bar: true, __typename: `Foo` }, bar: { baz: true } }, + }, + }); + + await expect(stream).toEmitMatchedValue({ + data: { + foo: { bar: true, baz: false, __typename: "Foo" }, + bar: { baz: true }, + }, + }); + }); + + it("should have access to query variables when running @client resolvers", async () => { + const query = gql` + query WithVariables($id: ID!) { + foo @client { + bar(id: $id) } - `; + } + `; - const resolvers = { - Query: { - foo: () => ({ __typename: "Foo" }), - }, - Foo: { - bar: (_data: any, { id }: { id: number }) => id, - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - variables: { id: 1 }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ foo: { bar: 1 } }); - } catch (error) { - reject(error); - } - resolve(); - }, - }, - }); - } - ); + const resolvers = { + Query: { + foo: () => ({ __typename: "Foo" }), + }, + Foo: { + bar: (_data: any, { id }: { id: number }) => id, + }, + }; + + const stream = setupTestWithResolvers({ + resolvers, + query, + variables: { id: 1 }, + }); + + await expect(stream).toEmitMatchedValue({ data: { foo: { bar: 1 } } }); + }); - itAsync("should pass context to @client resolvers", (resolve, reject) => { + it("should pass context to @client resolvers", async () => { const query = gql` query WithContext { foo @client { @@ -325,127 +256,99 @@ describe("Basic resolver capabilities", () => { }, }; - assertWithObserver({ - reject, + const stream = setupTestWithResolvers({ resolvers, query, queryOptions: { context: { id: 1 } }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ foo: { bar: 1 } }); - } catch (error) { - reject(error); - } - resolve(); - }, - }, }); + + await expect(stream).toEmitMatchedValue({ data: { foo: { bar: 1 } } }); }); - itAsync( - "should combine local @client resolver results with server results, for " + - "the same field", - (resolve, reject) => { - const query = gql` - query author { - author { - name - stats { - totalPosts - postsToday @client - } + it("should combine local @client resolver results with server results, for the same field", async () => { + const query = gql` + query author { + author { + name + stats { + totalPosts + postsToday @client } } - `; + } + `; - const serverQuery = gql` - query author { - author { - name - stats { - totalPosts - } + const serverQuery = gql` + query author { + author { + name + stats { + totalPosts } } - `; + } + `; - const resolvers = { - Stats: { - postsToday: () => 10, - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - serverQuery, - serverResult: { - data: { - author: { - name: "John Smith", - stats: { - totalPosts: 100, - __typename: "Stats", - }, - __typename: "Author", + const resolvers = { + Stats: { + postsToday: () => 10, + }, + }; + + const stream = setupTestWithResolvers({ + resolvers, + query, + serverQuery, + serverResult: { + data: { + author: { + name: "John Smith", + stats: { + totalPosts: 100, + __typename: "Stats", }, + __typename: "Author", }, }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ - author: { - name: "John Smith", - stats: { - totalPosts: 100, - postsToday: 10, - }, - }, - }); - } catch (error) { - reject(error); - } - resolve(); + }, + }); + + await expect(stream).toEmitMatchedValue({ + data: { + author: { + name: "John Smith", + stats: { + totalPosts: 100, + postsToday: 10, }, }, - }); - } - ); + }, + }); + }); - itAsync( - "should handle resolvers that work with booleans properly", - (resolve, reject) => { - const query = gql` - query CartDetails { - isInCart @client - } - `; + it("should handle resolvers that work with booleans properly", async () => { + const query = gql` + query CartDetails { + isInCart @client + } + `; - const cache = new InMemoryCache(); - cache.writeQuery({ query, data: { isInCart: true } }); + const cache = new InMemoryCache(); + cache.writeQuery({ query, data: { isInCart: true } }); - const client = new ApolloClient({ - cache, - resolvers: { - Query: { - isInCart: () => false, - }, + const client = new ApolloClient({ + cache, + resolvers: { + Query: { + isInCart: () => false, }, - }); + }, + }); - return client - .query({ query, fetchPolicy: "network-only" }) - .then(({ data }: any) => { - expect({ ...data }).toMatchObject({ - isInCart: false, - }); - resolve(); - }); - } - ); + const { data } = await client.query({ query, fetchPolicy: "network-only" }); + + expect(data).toMatchObject({ isInCart: false }); + }); it("should handle nested asynchronous @client resolvers (issue #4841)", () => { const query = gql` @@ -569,57 +472,47 @@ describe("Basic resolver capabilities", () => { ]); }); - itAsync( - "should not run resolvers without @client directive (issue #9571)", - (resolve, reject) => { - const query = gql` - query Mixed { - foo @client { - bar - } - bar { - baz - } + it("should not run resolvers without @client directive (issue #9571)", async () => { + const query = gql` + query Mixed { + foo @client { + bar + } + bar { + baz } - `; + } + `; - const serverQuery = gql` - query Mixed { - bar { - baz - } + const serverQuery = gql` + query Mixed { + bar { + baz } - `; + } + `; - const barResolver = jest.fn(() => ({ __typename: `Bar`, baz: false })); + const barResolver = jest.fn(() => ({ __typename: `Bar`, baz: false })); - const resolvers = { - Query: { - foo: () => ({ __typename: `Foo`, bar: true }), - bar: barResolver, - }, - }; - - assertWithObserver({ - reject, - resolvers, - query, - serverQuery, - serverResult: { data: { bar: { baz: true } } }, - observer: { - next({ data }) { - try { - expect(data).toEqual({ foo: { bar: true }, bar: { baz: true } }); - expect(barResolver).not.toHaveBeenCalled(); - } catch (error) { - reject(error); - } - resolve(); - }, - }, - }); - } - ); + const resolvers = { + Query: { + foo: () => ({ __typename: `Foo`, bar: true }), + bar: barResolver, + }, + }; + + const stream = setupTestWithResolvers({ + resolvers, + query, + serverQuery, + serverResult: { data: { bar: { baz: true } } }, + }); + + await expect(stream).toEmitMatchedValue({ + data: { foo: { bar: true }, bar: { baz: true } }, + }); + expect(barResolver).not.toHaveBeenCalled(); + }); }); describe("Writing cache data from resolvers", () => { @@ -777,440 +670,394 @@ describe("Writing cache data from resolvers", () => { }); describe("Resolving field aliases", () => { - itAsync( - "should run resolvers for missing client queries with aliased field", - (resolve, reject) => { - // expect.assertions(1); - const query = gql` - query Aliased { - foo @client { - bar - } - baz: bar { - foo - } + it("should run resolvers for missing client queries with aliased field", async () => { + const query = gql` + query Aliased { + foo @client { + bar } - `; + baz: bar { + foo + } + } + `; - const link = new ApolloLink(() => - // Each link is responsible for implementing their own aliasing so it - // returns baz not bar - Observable.of({ data: { baz: { foo: true, __typename: "Baz" } } }) - ); + const link = new ApolloLink(() => + // Each link is responsible for implementing their own aliasing so it + // returns baz not bar + Observable.of({ data: { baz: { foo: true, __typename: "Baz" } } }) + ); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Query: { - foo: () => ({ bar: true, __typename: "Foo" }), - }, + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { + Query: { + foo: () => ({ bar: true, __typename: "Foo" }), }, - }); + }, + }); - client.query({ query }).then(({ data }) => { - try { - expect(data).toEqual({ - foo: { bar: true, __typename: "Foo" }, - baz: { foo: true, __typename: "Baz" }, - }); - } catch (e) { - reject(e); - return; - } - resolve(); - }, reject); - } - ); + const { data } = await client.query({ query }); - itAsync( - "should run resolvers for client queries when aliases are in use on " + - "the @client-tagged node", - (resolve, reject) => { - const aliasedQuery = gql` - query Test { - fie: foo @client { - bar - } - } - `; - - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: ApolloLink.empty(), - resolvers: { - Query: { - foo: () => ({ bar: true, __typename: "Foo" }), - fie: () => { - reject( - "Called the resolver using the alias' name, instead of " + - "the correct resolver name." - ); - }, - }, - }, - }); + expect(data).toEqual({ + foo: { bar: true, __typename: "Foo" }, + baz: { foo: true, __typename: "Baz" }, + }); + }); - client.query({ query: aliasedQuery }).then(({ data }) => { - expect(data).toEqual({ fie: { bar: true, __typename: "Foo" } }); - resolve(); - }, reject); - } - ); + it("should run resolvers for client queries when aliases are in use on the @client-tagged node", async () => { + const aliasedQuery = gql` + query Test { + fie: foo @client { + bar + } + } + `; - itAsync( - "should respect aliases for *nested fields* on the @client-tagged node", - (resolve, reject) => { - const aliasedQuery = gql` - query Test { - fie: foo @client { - fum: bar - } - baz: bar { - foo - } + const fie = jest.fn(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + resolvers: { + Query: { + foo: () => ({ bar: true, __typename: "Foo" }), + fie, + }, + }, + }); + + const { data } = await client.query({ query: aliasedQuery }); + + expect(data).toEqual({ fie: { bar: true, __typename: "Foo" } }); + expect(fie).not.toHaveBeenCalled(); + }); + + it("should respect aliases for *nested fields* on the @client-tagged node", async () => { + const aliasedQuery = gql` + query Test { + fie: foo @client { + fum: bar } - `; + baz: bar { + foo + } + } + `; - const link = new ApolloLink(() => - Observable.of({ data: { baz: { foo: true, __typename: "Baz" } } }) - ); + const link = new ApolloLink(() => + Observable.of({ data: { baz: { foo: true, __typename: "Baz" } } }) + ); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Query: { - foo: () => ({ bar: true, __typename: "Foo" }), - fie: () => { - reject( - "Called the resolver using the alias' name, instead of " + - "the correct resolver name." - ); - }, - }, + const fie = jest.fn(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { + Query: { + foo: () => ({ bar: true, __typename: "Foo" }), + fie, }, - }); + }, + }); - client.query({ query: aliasedQuery }).then(({ data }) => { - expect(data).toEqual({ - fie: { fum: true, __typename: "Foo" }, - baz: { foo: true, __typename: "Baz" }, - }); - resolve(); - }, reject); - } - ); + const { data } = await client.query({ query: aliasedQuery }); + + expect(data).toEqual({ + fie: { fum: true, __typename: "Foo" }, + baz: { foo: true, __typename: "Baz" }, + }); + expect(fie).not.toHaveBeenCalled(); + }); - it( - "should pull initialized values for aliased fields tagged with @client " + - "from the cache", - () => { - const query = gql` + it("should pull initialized values for aliased fields tagged with @client from the cache", async () => { + const query = gql` + { + fie: foo @client { + bar + } + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + resolvers: {}, + }); + + cache.writeQuery({ + query: gql` { - fie: foo @client { + foo { bar } } - `; + `, + data: { + foo: { + bar: "yo", + __typename: "Foo", + }, + }, + }); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link: ApolloLink.empty(), - resolvers: {}, - }); + const { data } = await client.query({ query }); - cache.writeQuery({ - query: gql` - { - foo { - bar - } - } - `, + expect({ ...data }).toMatchObject({ + fie: { bar: "yo", __typename: "Foo" }, + }); + }); + + it("should resolve @client fields using local resolvers and not have their value overridden when a fragment is loaded", async () => { + const query = gql` + fragment LaunchDetails on Launch { + id + __typename + } + query Launch { + launch { + isInCart @client + ...LaunchDetails + } + } + `; + + const link = new ApolloLink(() => + Observable.of({ data: { - foo: { - bar: "yo", - __typename: "Foo", + launch: { + id: 1, + __typename: "Launch", }, }, - }); + }) + ); - return client.query({ query }).then(({ data }) => { - expect({ ...data }).toMatchObject({ - fie: { bar: "yo", __typename: "Foo" }, - }); - }); - } - ); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { + Launch: { + isInCart() { + return true; + }, + }, + }, + }); - it( - "should resolve @client fields using local resolvers and not have " + - "their value overridden when a fragment is loaded", - () => { - const query = gql` - fragment LaunchDetails on Launch { - id - __typename - } - query Launch { + client.writeQuery({ + query: gql` + { launch { - isInCart @client - ...LaunchDetails + isInCart } } - `; - - const link = new ApolloLink(() => - Observable.of({ - data: { - launch: { - id: 1, - __typename: "Launch", - }, - }, - }) - ); - - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Launch: { - isInCart() { - return true; - }, - }, + `, + data: { + launch: { + isInCart: false, + __typename: "Launch", }, - }); + }, + }); - client.writeQuery({ - query: gql` - { - launch { - isInCart - } - } - `, - data: { - launch: { - isInCart: false, - __typename: "Launch", - }, - }, - }); + { + const { data } = await client.query({ query }); + // `isInCart` resolver is fired, returning `true` (which is then + // stored in the cache). + expect(data.launch.isInCart).toBe(true); + } - return client - .query({ query }) - .then(({ data }) => { - // `isInCart` resolver is fired, returning `true` (which is then - // stored in the cache). - expect(data.launch.isInCart).toBe(true); - }) - .then(() => { - client.query({ query }).then(({ data }) => { - // When the same query fires again, `isInCart` should be pulled from - // the cache and have a value of `true`. - expect(data.launch.isInCart).toBe(true); - }); - }); + { + const { data } = await client.query({ query }); + // When the same query fires again, `isInCart` should be pulled from + // the cache and have a value of `true`. + expect(data.launch.isInCart).toBe(true); } - ); + }); }); describe("Force local resolvers", () => { - it( - "should force the running of local resolvers marked with " + - "`@client(always: true)` when using `ApolloClient.query`", - async () => { - const query = gql` - query Author { - author { - name - isLoggedIn @client(always: true) - } + it("should force the running of local resolvers marked with `@client(always: true)` when using `ApolloClient.query`", async () => { + const query = gql` + query Author { + author { + name + isLoggedIn @client(always: true) } - `; + } + `; - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link: ApolloLink.empty(), - resolvers: {}, - }); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + resolvers: {}, + }); + + cache.writeQuery({ + query, + data: { + author: { + name: "John Smith", + isLoggedIn: false, + __typename: "Author", + }, + }, + }); + + // When the resolver isn't defined, there isn't anything to force, so + // make sure the query resolves from the cache properly. + const { data: data1 } = await client.query({ query }); + expect(data1.author.isLoggedIn).toEqual(false); + + client.addResolvers({ + Author: { + isLoggedIn() { + return true; + }, + }, + }); + + // A resolver is defined, so make sure it's forced, and the result + // resolves properly as a combination of cache and local resolver + // data. + const { data: data2 } = await client.query({ query }); + expect(data2.author.isLoggedIn).toEqual(true); + }); + + it("should avoid running forced resolvers a second time when loading results over the network (so not from the cache)", async () => { + const query = gql` + query Author { + author { + name + isLoggedIn @client(always: true) + } + } + `; - cache.writeQuery({ - query, + const link = new ApolloLink(() => + Observable.of({ data: { author: { name: "John Smith", - isLoggedIn: false, __typename: "Author", }, }, - }); - - // When the resolver isn't defined, there isn't anything to force, so - // make sure the query resolves from the cache properly. - const { data: data1 } = await client.query({ query }); - expect(data1.author.isLoggedIn).toEqual(false); + }) + ); - client.addResolvers({ + let count = 0; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { Author: { isLoggedIn() { + count += 1; return true; }, }, - }); + }, + }); - // A resolver is defined, so make sure it's forced, and the result - // resolves properly as a combination of cache and local resolver - // data. - const { data: data2 } = await client.query({ query }); - expect(data2.author.isLoggedIn).toEqual(true); - } - ); + const { data } = await client.query({ query }); + expect(data.author.isLoggedIn).toEqual(true); + expect(count).toEqual(1); + }); - it( - "should avoid running forced resolvers a second time when " + - "loading results over the network (so not from the cache)", - async () => { - const query = gql` - query Author { - author { - name - isLoggedIn @client(always: true) - } - } - `; - - const link = new ApolloLink(() => - Observable.of({ - data: { - author: { - name: "John Smith", - __typename: "Author", - }, - }, - }) - ); + it("should only force resolvers for fields marked with `@client(always: true)`, not all `@client` fields", async () => { + const query = gql` + query UserDetails { + name @client + isLoggedIn @client(always: true) + } + `; - let count = 0; - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Author: { - isLoggedIn() { - count += 1; - return true; - }, + let nameCount = 0; + let isLoggedInCount = 0; + const client = new ApolloClient({ + cache: new InMemoryCache(), + resolvers: { + Query: { + name() { + nameCount += 1; + return "John Smith"; + }, + isLoggedIn() { + isLoggedInCount += 1; + return true; }, }, - }); + }, + }); - const { data } = await client.query({ query }); - expect(data.author.isLoggedIn).toEqual(true); - expect(count).toEqual(1); - } - ); + await client.query({ query }); + expect(nameCount).toEqual(1); + expect(isLoggedInCount).toEqual(1); - it( - "should only force resolvers for fields marked with " + - "`@client(always: true)`, not all `@client` fields", - async () => { - const query = gql` - query UserDetails { - name @client - isLoggedIn @client(always: true) - } - `; - - let nameCount = 0; - let isLoggedInCount = 0; - const client = new ApolloClient({ - cache: new InMemoryCache(), - resolvers: { - Query: { - name() { - nameCount += 1; - return "John Smith"; - }, - isLoggedIn() { - isLoggedInCount += 1; - return true; - }, + // On the next request, `name` will be loaded from the cache only, + // whereas `isLoggedIn` will be loaded from the cache then overwritten + // by running its forced local resolver. + await client.query({ query }); + expect(nameCount).toEqual(1); + expect(isLoggedInCount).toEqual(2); + }); + + it("should force the running of local resolvers marked with `@client(always: true)` when using `ApolloClient.watchQuery`", async () => { + const query = gql` + query IsUserLoggedIn { + isUserLoggedIn @client(always: true) + } + `; + + const queryNoForce = gql` + query IsUserLoggedIn { + isUserLoggedIn @client + } + `; + + let callCount = 0; + const client = new ApolloClient({ + cache: new InMemoryCache(), + resolvers: { + Query: { + isUserLoggedIn() { + callCount += 1; + return true; }, }, - }); + }, + }); - await client.query({ query }); - expect(nameCount).toEqual(1); - expect(isLoggedInCount).toEqual(1); + { + const stream = new ObservableStream(client.watchQuery({ query })); - // On the next request, `name` will be loaded from the cache only, - // whereas `isLoggedIn` will be loaded from the cache then overwritten - // by running its forced local resolver. - await client.query({ query }); - expect(nameCount).toEqual(1); - expect(isLoggedInCount).toEqual(2); + await expect(stream).toEmitNext(); + expect(callCount).toBe(1); } - ); - itAsync( - "should force the running of local resolvers marked with " + - "`@client(always: true)` when using `ApolloClient.watchQuery`", - (resolve, reject) => { - const query = gql` - query IsUserLoggedIn { - isUserLoggedIn @client(always: true) - } - `; - - const queryNoForce = gql` - query IsUserLoggedIn { - isUserLoggedIn @client - } - `; - - let callCount = 0; - const client = new ApolloClient({ - cache: new InMemoryCache(), - resolvers: { - Query: { - isUserLoggedIn() { - callCount += 1; - return true; - }, - }, - }, - }); + { + const stream = new ObservableStream(client.watchQuery({ query })); - client.watchQuery({ query }).subscribe({ - next() { - expect(callCount).toBe(1); + await expect(stream).toEmitNext(); + expect(callCount).toBe(2); + } - client.watchQuery({ query }).subscribe({ - next() { - expect(callCount).toBe(2); + { + const stream = new ObservableStream( + client.watchQuery({ query: queryNoForce }) + ); - client.watchQuery({ query: queryNoForce }).subscribe({ - next() { - // Result is loaded from the cache since the resolver - // isn't being forced. - expect(callCount).toBe(2); - resolve(); - }, - }); - }, - }); - }, - }); + await expect(stream).toEmitNext(); + // Result is loaded from the cache since the resolver + // isn't being forced. + expect(callCount).toBe(2); } - ); + }); - it("should allow client-only virtual resolvers (#4731)", function () { + it("should allow client-only virtual resolvers (#4731)", async () => { const query = gql` query UserData { userData @client { @@ -1241,21 +1088,21 @@ describe("Force local resolvers", () => { }, }); - return client.query({ query }).then((result) => { - expect(result.data).toEqual({ - userData: { - __typename: "User", - firstName: "Ben", - lastName: "Newman", - fullName: "Ben Newman", - }, - }); + const result = await client.query({ query }); + + expect(result.data).toEqual({ + userData: { + __typename: "User", + firstName: "Ben", + lastName: "Newman", + fullName: "Ben Newman", + }, }); }); }); describe("Async resolvers", () => { - itAsync("should support async @client resolvers", async (resolve, reject) => { + it("should support async @client resolvers", async () => { const query = gql` query Member { isLoggedIn @client @@ -1276,64 +1123,61 @@ describe("Async resolvers", () => { const { data: { isLoggedIn }, } = await client.query({ query })!; + expect(isLoggedIn).toBe(true); - return resolve(); }); - itAsync( - "should support async @client resolvers mixed with remotely resolved data", - async (resolve, reject) => { - const query = gql` - query Member { - member { - name - sessionCount @client - isLoggedIn @client - } + it("should support async @client resolvers mixed with remotely resolved data", async () => { + const query = gql` + query Member { + member { + name + sessionCount @client + isLoggedIn @client } - `; - - const testMember = { - name: "John Smithsonian", - isLoggedIn: true, - sessionCount: 10, - }; - - const link = new ApolloLink(() => - Observable.of({ - data: { - member: { - name: testMember.name, - __typename: "Member", - }, + } + `; + + const testMember = { + name: "John Smithsonian", + isLoggedIn: true, + sessionCount: 10, + }; + + const link = new ApolloLink(() => + Observable.of({ + data: { + member: { + name: testMember.name, + __typename: "Member", }, - }) - ); + }, + }) + ); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link, - resolvers: { - Member: { - isLoggedIn() { - return Promise.resolve(testMember.isLoggedIn); - }, - sessionCount() { - return testMember.sessionCount; - }, + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + resolvers: { + Member: { + isLoggedIn() { + return Promise.resolve(testMember.isLoggedIn); + }, + sessionCount() { + return testMember.sessionCount; }, }, - }); + }, + }); - const { - data: { member }, - } = await client.query({ query })!; - expect(member.name).toBe(testMember.name); - expect(member.isLoggedIn).toBe(testMember.isLoggedIn); - expect(member.sessionCount).toBe(testMember.sessionCount); - return resolve(); - } - ); + const { + data: { member }, + } = await client.query({ query })!; + + expect(member.name).toBe(testMember.name); + expect(member.isLoggedIn).toBe(testMember.isLoggedIn); + expect(member.sessionCount).toBe(testMember.sessionCount); + }); }); describe("LocalState helpers", () => { diff --git a/src/cache/inmemory/__tests__/fragmentMatcher.ts b/src/cache/inmemory/__tests__/fragmentMatcher.ts index 29bce194c2d..6823819226b 100644 --- a/src/cache/inmemory/__tests__/fragmentMatcher.ts +++ b/src/cache/inmemory/__tests__/fragmentMatcher.ts @@ -1,6 +1,5 @@ import gql from "graphql-tag"; -import { itAsync } from "../../../testing"; import { InMemoryCache } from "../inMemoryCache"; import { visit, FragmentDefinitionNode } from "graphql"; import { hasOwn } from "../helpers"; @@ -242,7 +241,7 @@ describe("policies.fragmentMatches", () => { console.warn = warn; }); - itAsync("can infer fuzzy subtypes heuristically", (resolve, reject) => { + it("can infer fuzzy subtypes heuristically", async () => { const cache = new InMemoryCache({ possibleTypes: { A: ["B", "C"], @@ -279,7 +278,7 @@ describe("policies.fragmentMatches", () => { FragmentDefinition(frag) { function check(typename: string, result: boolean) { if (result !== cache.policies.fragmentMatches(frag, typename)) { - reject( + throw new Error( `fragment ${frag.name.value} should${ result ? "" : " not" } have matched typename ${typename}` @@ -577,7 +576,5 @@ describe("policies.fragmentMatches", () => { }, }).size ).toBe("ABCDEF".length); - - resolve(); }); }); diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index 8c8bf1c5b48..f147f5d49d4 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -19,7 +19,6 @@ import { cloneDeep, getMainDefinition, } from "../../../utilities"; -import { itAsync } from "../../../testing/core"; import { StoreWriter } from "../writeToStore"; import { defaultNormalizedCacheFactory, writeQueryToStore } from "./helpers"; import { InMemoryCache } from "../inMemoryCache"; @@ -1860,137 +1859,132 @@ describe("writing to the store", () => { expect(cache.extract()).toMatchSnapshot(); }); - itAsync( - "should allow a union of objects of a different type, when overwriting a generated id with a real id", - (resolve, reject) => { - const dataWithPlaceholder = { - author: { - hello: "Foo", - __typename: "Placeholder", - }, - }; - const dataWithAuthor = { - author: { - firstName: "John", - lastName: "Smith", - id: "129", - __typename: "Author", - }, - }; - const query = gql` - query { - author { - ... on Author { - firstName - lastName - id - __typename - } - ... on Placeholder { - hello - __typename - } + it("should allow a union of objects of a different type, when overwriting a generated id with a real id", async () => { + const dataWithPlaceholder = { + author: { + hello: "Foo", + __typename: "Placeholder", + }, + }; + const dataWithAuthor = { + author: { + firstName: "John", + lastName: "Smith", + id: "129", + __typename: "Author", + }, + }; + const query = gql` + query { + author { + ... on Author { + firstName + lastName + id + __typename + } + ... on Placeholder { + hello + __typename } } - `; + } + `; - let mergeCount = 0; - const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - author: { - merge(existing, incoming, { isReference, readField }) { - switch (++mergeCount) { - case 1: - expect(existing).toBeUndefined(); - expect(isReference(incoming)).toBe(false); - expect(incoming).toEqual(dataWithPlaceholder.author); - break; - case 2: - expect(existing).toEqual(dataWithPlaceholder.author); - expect(isReference(incoming)).toBe(true); - expect(readField("__typename", incoming)).toBe("Author"); - break; - case 3: - expect(isReference(existing)).toBe(true); - expect(readField("__typename", existing)).toBe("Author"); - expect(incoming).toEqual(dataWithPlaceholder.author); - break; - default: - reject("unreached"); - } - return incoming; - }, + let mergeCount = 0; + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + author: { + merge(existing, incoming, { isReference, readField }) { + switch (++mergeCount) { + case 1: + expect(existing).toBeUndefined(); + expect(isReference(incoming)).toBe(false); + expect(incoming).toEqual(dataWithPlaceholder.author); + break; + case 2: + expect(existing).toEqual(dataWithPlaceholder.author); + expect(isReference(incoming)).toBe(true); + expect(readField("__typename", incoming)).toBe("Author"); + break; + case 3: + expect(isReference(existing)).toBe(true); + expect(readField("__typename", existing)).toBe("Author"); + expect(incoming).toEqual(dataWithPlaceholder.author); + break; + default: + throw new Error("unreached"); + } + return incoming; }, }, }, }, - }); + }, + }); - // write the first object, without an ID, placeholder - cache.writeQuery({ - query, - data: dataWithPlaceholder, - }); + // write the first object, without an ID, placeholder + cache.writeQuery({ + query, + data: dataWithPlaceholder, + }); - expect(cache.extract()).toEqual({ - ROOT_QUERY: { - __typename: "Query", - author: { - hello: "Foo", - __typename: "Placeholder", - }, + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + author: { + hello: "Foo", + __typename: "Placeholder", }, - }); + }, + }); - // replace with another one of different type with ID - cache.writeQuery({ - query, - data: dataWithAuthor, - }); + // replace with another one of different type with ID + cache.writeQuery({ + query, + data: dataWithAuthor, + }); - expect(cache.extract()).toEqual({ - "Author:129": { - firstName: "John", - lastName: "Smith", - id: "129", - __typename: "Author", - }, - ROOT_QUERY: { - __typename: "Query", - author: makeReference("Author:129"), - }, - }); + expect(cache.extract()).toEqual({ + "Author:129": { + firstName: "John", + lastName: "Smith", + id: "129", + __typename: "Author", + }, + ROOT_QUERY: { + __typename: "Query", + author: makeReference("Author:129"), + }, + }); - // and go back to the original: - cache.writeQuery({ - query, - data: dataWithPlaceholder, - }); + // and go back to the original: + cache.writeQuery({ + query, + data: dataWithPlaceholder, + }); - // Author__129 will remain in the store, - // but will not be referenced by any of the fields, - // hence we combine, and in that very order - expect(cache.extract()).toEqual({ - "Author:129": { - firstName: "John", - lastName: "Smith", - id: "129", - __typename: "Author", - }, - ROOT_QUERY: { - __typename: "Query", - author: { - hello: "Foo", - __typename: "Placeholder", - }, + // Author__129 will remain in the store, + // but will not be referenced by any of the fields, + // hence we combine, and in that very order + expect(cache.extract()).toEqual({ + "Author:129": { + firstName: "John", + lastName: "Smith", + id: "129", + __typename: "Author", + }, + ROOT_QUERY: { + __typename: "Query", + author: { + hello: "Foo", + __typename: "Placeholder", }, - }); - - resolve(); - } - ); + }, + }); + }); it("does not swallow errors other than field errors", () => { const query = gql` @@ -2888,29 +2882,28 @@ describe("writing to the store", () => { expect(mergeCounts).toEqual({ first: 1, second: 1, third: 1, fourth: 1 }); }); - itAsync( - "should allow silencing broadcast of cache updates", - function (resolve, reject) { - const cache = new InMemoryCache({ - typePolicies: { - Counter: { - // Counter is a singleton, but we want to be able to test - // writing to it with writeFragment, so it needs to have an ID. - keyFields: [], - }, + it("should allow silencing broadcast of cache updates", async () => { + const cache = new InMemoryCache({ + typePolicies: { + Counter: { + // Counter is a singleton, but we want to be able to test + // writing to it with writeFragment, so it needs to have an ID. + keyFields: [], }, - }); + }, + }); - const query = gql` - query { - counter { - count - } + const query = gql` + query { + counter { + count } - `; + } + `; - const results: number[] = []; + const results: number[] = []; + const promise = new Promise((resolve) => { cache.watch({ query, optimistic: true, @@ -2925,101 +2918,103 @@ describe("writing to the store", () => { resolve(); }, }); + }); - let count = 0; - - cache.writeQuery({ - query, - data: { - counter: { - __typename: "Counter", - count: ++count, - }, - }, - broadcast: false, - }); + let count = 0; - expect(cache.extract()).toEqual({ - ROOT_QUERY: { - __typename: "Query", - counter: { __ref: "Counter:{}" }, - }, - "Counter:{}": { + cache.writeQuery({ + query, + data: { + counter: { __typename: "Counter", - count: 1, + count: ++count, }, - }); + }, + broadcast: false, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { + __typename: "Counter", + count: 1, + }, + }); + + expect(results).toEqual([]); + + const counterId = cache.identify({ + __typename: "Counter", + })!; + + cache.writeFragment({ + id: counterId, + fragment: gql` + fragment Count on Counter { + count + } + `, + data: { + count: ++count, + }, + broadcast: false, + }); - expect(results).toEqual([]); + const counterMeta = { + extraRootIds: ["Counter:{}"], + }; - const counterId = cache.identify({ + expect(cache.extract()).toEqual({ + __META: counterMeta, + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { __typename: "Counter", - })!; + count: 2, + }, + }); - cache.writeFragment({ + expect(results).toEqual([]); + + expect( + cache.evict({ id: counterId, - fragment: gql` - fragment Count on Counter { - count - } - `, - data: { - count: ++count, - }, + fieldName: "count", broadcast: false, - }); - - const counterMeta = { - extraRootIds: ["Counter:{}"], - }; - - expect(cache.extract()).toEqual({ - __META: counterMeta, - ROOT_QUERY: { - __typename: "Query", - counter: { __ref: "Counter:{}" }, - }, - "Counter:{}": { - __typename: "Counter", - count: 2, - }, - }); + }) + ).toBe(true); - expect(results).toEqual([]); + expect(cache.extract()).toEqual({ + __META: counterMeta, + ROOT_QUERY: { + __typename: "Query", + counter: { __ref: "Counter:{}" }, + }, + "Counter:{}": { + __typename: "Counter", + }, + }); - expect( - cache.evict({ - id: counterId, - fieldName: "count", - broadcast: false, - }) - ).toBe(true); + expect(results).toEqual([]); - expect(cache.extract()).toEqual({ - __META: counterMeta, - ROOT_QUERY: { - __typename: "Query", - counter: { __ref: "Counter:{}" }, - }, - "Counter:{}": { + // Only this write should trigger a broadcast. + cache.writeQuery({ + query, + data: { + counter: { __typename: "Counter", + count: 3, }, - }); - - expect(results).toEqual([]); + }, + }); - // Only this write should trigger a broadcast. - cache.writeQuery({ - query, - data: { - counter: { - __typename: "Counter", - count: 3, - }, - }, - }); - } - ); + await promise; + }); it("writeFragment should be able to infer ROOT_QUERY", () => { const cache = new InMemoryCache(); diff --git a/src/core/__tests__/QueryManager/links.ts b/src/core/__tests__/QueryManager/links.ts index 53d3b22f6bb..de43efdf8b9 100644 --- a/src/core/__tests__/QueryManager/links.ts +++ b/src/core/__tests__/QueryManager/links.ts @@ -10,7 +10,7 @@ import { ApolloLink } from "../../../link/core"; import { InMemoryCache } from "../../../cache/inmemory/inMemoryCache"; // mocks -import { itAsync, MockSubscriptionLink } from "../../../testing/core"; +import { MockSubscriptionLink } from "../../../testing/core"; // core import { QueryManager } from "../../QueryManager"; @@ -18,308 +18,277 @@ import { NextLink, Operation, Reference } from "../../../core"; import { getDefaultOptionsForQueryManagerTests } from "../../../testing/core/mocking/mockQueryManager"; describe("Link interactions", () => { - itAsync( - "includes the cache on the context for eviction links", - (resolve, reject) => { - const query = gql` - query CachedLuke { - people_one(id: 1) { + it("includes the cache on the context for eviction links", (done) => { + const query = gql` + query CachedLuke { + people_one(id: 1) { + name + friends { name - friends { - name - } } } - `; + } + `; - const initialData = { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "Leia Skywalker" }], - }, - }; - - const evictionLink = (operation: Operation, forward: NextLink) => { - const { cache } = operation.getContext(); - expect(cache).toBeDefined(); - return forward(operation).map((result) => { - setTimeout(() => { - const cacheResult = cache.read({ query }); - expect(cacheResult).toEqual(initialData); - expect(cacheResult).toEqual(result.data); - if (count === 1) { - resolve(); - } - }, 10); - return result; - }); - }; - - const mockLink = new MockSubscriptionLink(); - const link = ApolloLink.from([evictionLink, mockLink]); - const queryManager = new QueryManager( - getDefaultOptionsForQueryManagerTests({ - cache: new InMemoryCache({ addTypename: false }), - link, - }) - ); - - const observable = queryManager.watchQuery({ - query, - variables: {}, - }); + const initialData = { + people_one: { + name: "Luke Skywalker", + friends: [{ name: "Leia Skywalker" }], + }, + }; - let count = 0; - observable.subscribe({ - next: (result) => { - count++; - }, - error: (e) => { - console.error(e); - }, + const evictionLink = (operation: Operation, forward: NextLink) => { + const { cache } = operation.getContext(); + expect(cache).toBeDefined(); + return forward(operation).map((result) => { + setTimeout(() => { + const cacheResult = cache.read({ query }); + expect(cacheResult).toEqual(initialData); + expect(cacheResult).toEqual(result.data); + if (count === 1) { + done(); + } + }, 10); + return result; }); + }; + + const mockLink = new MockSubscriptionLink(); + const link = ApolloLink.from([evictionLink, mockLink]); + const queryManager = new QueryManager( + getDefaultOptionsForQueryManagerTests({ + cache: new InMemoryCache({ addTypename: false }), + link, + }) + ); + + const observable = queryManager.watchQuery({ + query, + variables: {}, + }); - // fire off first result - mockLink.simulateResult({ result: { data: initialData } }); - } - ); - itAsync( - "cleans up all links on the final unsubscribe from watchQuery", - (resolve, reject) => { - const query = gql` - query WatchedLuke { - people_one(id: 1) { + let count = 0; + observable.subscribe({ + next: (result) => { + count++; + }, + error: (e) => { + console.error(e); + }, + }); + + // fire off first result + mockLink.simulateResult({ result: { data: initialData } }); + }); + + it("cleans up all links on the final unsubscribe from watchQuery", (done) => { + const query = gql` + query WatchedLuke { + people_one(id: 1) { + name + friends { name - friends { - name - } } } - `; + } + `; - const initialData = { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "Leia Skywalker" }], - }, - }; - - const link = new MockSubscriptionLink(); - const queryManager = new QueryManager( - getDefaultOptionsForQueryManagerTests({ - cache: new InMemoryCache({ addTypename: false }), - link, - }) - ); - - const observable = queryManager.watchQuery({ - query, - variables: {}, - }); + const initialData = { + people_one: { + name: "Luke Skywalker", + friends: [{ name: "Leia Skywalker" }], + }, + }; - let count = 0; - let four: ObservableSubscription; - // first watch - const one = observable.subscribe((result) => count++); - // second watch - const two = observable.subscribe((result) => count++); - // third watch (to be unsubscribed) - const three = observable.subscribe((result) => { - count++; - three.unsubscribe(); - // fourth watch - four = observable.subscribe((x) => count++); - }); + const link = new MockSubscriptionLink(); + const queryManager = new QueryManager( + getDefaultOptionsForQueryManagerTests({ + cache: new InMemoryCache({ addTypename: false }), + link, + }) + ); - // fire off first result - link.simulateResult({ result: { data: initialData } }); - setTimeout(() => { - one.unsubscribe(); - - link.simulateResult({ - result: { - data: { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "R2D2" }], - }, + const observable = queryManager.watchQuery({ + query, + variables: {}, + }); + + let count = 0; + let four: ObservableSubscription; + // first watch + const one = observable.subscribe((result) => count++); + // second watch + const two = observable.subscribe((result) => count++); + // third watch (to be unsubscribed) + const three = observable.subscribe((result) => { + count++; + three.unsubscribe(); + // fourth watch + four = observable.subscribe((x) => count++); + }); + + // fire off first result + link.simulateResult({ result: { data: initialData } }); + setTimeout(() => { + one.unsubscribe(); + + link.simulateResult({ + result: { + data: { + people_one: { + name: "Luke Skywalker", + friends: [{ name: "R2D2" }], }, }, - }); - setTimeout(() => { - four.unsubscribe(); - // final unsubscribe should be called now - two.unsubscribe(); - }, 10); + }, + }); + setTimeout(() => { + four.unsubscribe(); + // final unsubscribe should be called now + two.unsubscribe(); }, 10); + }, 10); - link.onUnsubscribe(() => { - expect(count).toEqual(6); - resolve(); - }); - } - ); - itAsync( - "cleans up all links on the final unsubscribe from watchQuery [error]", - (resolve, reject) => { - const query = gql` - query WatchedLuke { - people_one(id: 1) { + link.onUnsubscribe(() => { + expect(count).toEqual(6); + done(); + }); + }); + + it("cleans up all links on the final unsubscribe from watchQuery [error]", (done) => { + const query = gql` + query WatchedLuke { + people_one(id: 1) { + name + friends { name - friends { - name - } } } - `; + } + `; - const initialData = { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "Leia Skywalker" }], - }, - }; - - const link = new MockSubscriptionLink(); - const queryManager = new QueryManager( - getDefaultOptionsForQueryManagerTests({ - cache: new InMemoryCache({ addTypename: false }), - link, - }) - ); - - const observable = queryManager.watchQuery({ - query, - variables: {}, - }); + const initialData = { + people_one: { + name: "Luke Skywalker", + friends: [{ name: "Leia Skywalker" }], + }, + }; - let count = 0; - let four: ObservableSubscription; - // first watch - const one = observable.subscribe((result) => count++); - // second watch - observable.subscribe({ - next: () => count++, - error: () => { - count = 0; - }, - }); - // third watch (to be unsubscribed) - const three = observable.subscribe((result) => { - count++; - three.unsubscribe(); - // fourth watch - four = observable.subscribe((x) => count++); - }); + const link = new MockSubscriptionLink(); + const queryManager = new QueryManager( + getDefaultOptionsForQueryManagerTests({ + cache: new InMemoryCache({ addTypename: false }), + link, + }) + ); - // fire off first result - link.simulateResult({ result: { data: initialData } }); - setTimeout(() => { - one.unsubscribe(); - four.unsubscribe(); + const observable = queryManager.watchQuery({ + query, + variables: {}, + }); - // final unsubscribe should be called now - // since errors clean up subscriptions - link.simulateResult({ error: new Error("dang") }); + let count = 0; + let four: ObservableSubscription; + // first watch + const one = observable.subscribe((result) => count++); + // second watch + observable.subscribe({ + next: () => count++, + error: () => { + count = 0; + }, + }); + // third watch (to be unsubscribed) + const three = observable.subscribe((result) => { + count++; + three.unsubscribe(); + // fourth watch + four = observable.subscribe((x) => count++); + }); - setTimeout(() => { - expect(count).toEqual(0); - resolve(); - }, 10); + // fire off first result + link.simulateResult({ result: { data: initialData } }); + setTimeout(() => { + one.unsubscribe(); + four.unsubscribe(); + + // final unsubscribe should be called now + // since errors clean up subscriptions + link.simulateResult({ error: new Error("dang") }); + + setTimeout(() => { + expect(count).toEqual(0); + done(); }, 10); + }, 10); - link.onUnsubscribe(() => { - expect(count).toEqual(4); - }); - } - ); - itAsync( - "includes the cache on the context for mutations", - (resolve, reject) => { - const mutation = gql` - mutation UpdateLuke { - people_one(id: 1) { + link.onUnsubscribe(() => { + expect(count).toEqual(4); + }); + }); + + it("includes the cache on the context for mutations", (done) => { + const mutation = gql` + mutation UpdateLuke { + people_one(id: 1) { + name + friends { name - friends { - name - } } } - `; + } + `; - const initialData = { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "Leia Skywalker" }], - }, - }; - - const evictionLink = (operation: Operation, forward: NextLink) => { - const { cache } = operation.getContext(); - expect(cache).toBeDefined(); - resolve(); - return forward(operation); - }; - - const mockLink = new MockSubscriptionLink(); - const link = ApolloLink.from([evictionLink, mockLink]); - const queryManager = new QueryManager( - getDefaultOptionsForQueryManagerTests({ - cache: new InMemoryCache({ addTypename: false }), - link, - }) - ); - - queryManager.mutate({ mutation }); - - // fire off first result - mockLink.simulateResult({ result: { data: initialData } }); - } - ); - - itAsync( - "includes passed context in the context for mutations", - (resolve, reject) => { - const mutation = gql` - mutation UpdateLuke { - people_one(id: 1) { + const evictionLink = (operation: Operation, forward: NextLink) => { + const { cache } = operation.getContext(); + expect(cache).toBeDefined(); + done(); + return forward(operation); + }; + + const mockLink = new MockSubscriptionLink(); + const link = ApolloLink.from([evictionLink, mockLink]); + const queryManager = new QueryManager( + getDefaultOptionsForQueryManagerTests({ + cache: new InMemoryCache({ addTypename: false }), + link, + }) + ); + + void queryManager.mutate({ mutation }); + }); + + it("includes passed context in the context for mutations", (done) => { + const mutation = gql` + mutation UpdateLuke { + people_one(id: 1) { + name + friends { name - friends { - name - } } } - `; + } + `; + + const evictionLink = (operation: Operation, forward: NextLink) => { + const { planet } = operation.getContext(); + expect(planet).toBe("Tatooine"); + done(); + return forward(operation); + }; + + const mockLink = new MockSubscriptionLink(); + const link = ApolloLink.from([evictionLink, mockLink]); + const queryManager = new QueryManager( + getDefaultOptionsForQueryManagerTests({ + cache: new InMemoryCache({ addTypename: false }), + link, + }) + ); + + void queryManager.mutate({ mutation, context: { planet: "Tatooine" } }); + }); - const initialData = { - people_one: { - name: "Luke Skywalker", - friends: [{ name: "Leia Skywalker" }], - }, - }; - - const evictionLink = (operation: Operation, forward: NextLink) => { - const { planet } = operation.getContext(); - expect(planet).toBe("Tatooine"); - resolve(); - return forward(operation); - }; - - const mockLink = new MockSubscriptionLink(); - const link = ApolloLink.from([evictionLink, mockLink]); - const queryManager = new QueryManager( - getDefaultOptionsForQueryManagerTests({ - cache: new InMemoryCache({ addTypename: false }), - link, - }) - ); - - queryManager.mutate({ mutation, context: { planet: "Tatooine" } }); - - // fire off first result - mockLink.simulateResult({ result: { data: initialData } }); - } - ); it("includes getCacheKey function on the context for cache resolvers", async () => { const query = gql` { diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts index 0208b6982c5..8042f2712b4 100644 --- a/src/core/__tests__/fetchPolicies.ts +++ b/src/core/__tests__/fetchPolicies.ts @@ -4,7 +4,7 @@ import { ApolloClient, NetworkStatus } from "../../core"; import { ApolloLink } from "../../link/core"; import { InMemoryCache } from "../../cache"; import { Observable } from "../../utilities"; -import { itAsync, mockSingleLink } from "../../testing"; +import { mockSingleLink } from "../../testing"; import { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { WatchQueryFetchPolicy, WatchQueryOptions } from "../watchQueryOptions"; import { ApolloQueryResult } from "../types"; @@ -56,7 +56,7 @@ const mutationResult = { const merged = { author: { ...result.author, firstName: "James" } }; -const createLink = (reject: (reason: any) => any) => +const createLink = () => mockSingleLink( { request: { query }, @@ -66,7 +66,7 @@ const createLink = (reject: (reason: any) => any) => request: { query }, result: { data: result }, } - ).setOnError(reject); + ); const createFailureLink = () => mockSingleLink( @@ -80,7 +80,7 @@ const createFailureLink = () => } ); -const createMutationLink = (reject: (reason: any) => any) => +const createMutationLink = () => // fetch the data mockSingleLink( { @@ -95,41 +95,35 @@ const createMutationLink = (reject: (reason: any) => any) => request: { query }, result: { data: merged }, } - ).setOnError(reject); + ); describe("network-only", () => { - itAsync( - "requests from the network even if already in cache", - (resolve, reject) => { - let called = 0; - const inspector = new ApolloLink((operation, forward) => { + it("requests from the network even if already in cache", async () => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map((result) => { called++; - return forward(operation).map((result) => { - called++; - return result; - }); + return result; }); + }); - const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link: inspector.concat(createLink()), + cache: new InMemoryCache({ addTypename: false }), + }); - return client - .query({ query }) - .then(() => - client - .query({ fetchPolicy: "network-only", query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect(called).toBe(4); - }) - ) - .then(resolve, reject); - } - ); + await client.query({ query }); + const actualResult = await client.query({ + fetchPolicy: "network-only", + query, + }); + + expect(actualResult.data).toEqual(result); + expect(called).toBe(4); + }); - itAsync("saves data to the cache on success", (resolve, reject) => { + it("saves data to the cache on success", async () => { let called = 0; const inspector = new ApolloLink((operation, forward) => { called++; @@ -140,22 +134,18 @@ describe("network-only", () => { }); const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), + link: inspector.concat(createLink()), cache: new InMemoryCache({ addTypename: false }), }); - return client - .query({ query, fetchPolicy: "network-only" }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect(called).toBe(2); - }) - ) - .then(resolve, reject); + await client.query({ query, fetchPolicy: "network-only" }); + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + expect(called).toBe(2); }); - itAsync("does not save data to the cache on failure", (resolve, reject) => { + it("does not save data to the cache on failure", async () => { let called = 0; const inspector = new ApolloLink((operation, forward) => { called++; @@ -171,24 +161,20 @@ describe("network-only", () => { }); let didFail = false; - return client - .query({ query, fetchPolicy: "network-only" }) - .catch((e) => { - expect(e.message).toMatch("query failed"); - didFail = true; - }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - // the first error doesn't call .map on the inspector - expect(called).toBe(3); - expect(didFail).toBe(true); - }) - ) - .then(resolve, reject); + await client.query({ query, fetchPolicy: "network-only" }).catch((e) => { + expect(e.message).toMatch("query failed"); + didFail = true; + }); + + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + // the first error doesn't call .map on the inspector + expect(called).toBe(3); + expect(didFail).toBe(true); }); - itAsync("updates the cache on a mutation", (resolve, reject) => { + it("updates the cache on a mutation", async () => { const inspector = new ApolloLink((operation, forward) => { return forward(operation).map((result) => { return result; @@ -196,28 +182,23 @@ describe("network-only", () => { }); const client = new ApolloClient({ - link: inspector.concat(createMutationLink(reject)), + link: inspector.concat(createMutationLink()), cache: new InMemoryCache({ addTypename: false }), }); - return client - .query({ query }) - .then(() => - // XXX currently only no-cache is supported as a fetchPolicy - // this mainly serves to ensure the cache is updated correctly - client.mutate({ mutation, variables }) - ) - .then(() => { - return client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(merged); - }); - }) - .then(resolve, reject); + await client.query({ query }); + // XXX currently only no-cache is supported as a fetchPolicy + // this mainly serves to ensure the cache is updated correctly + await client.mutate({ mutation, variables }); + + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(merged); }); }); describe("no-cache", () => { - itAsync("requests from the network when not in cache", (resolve, reject) => { + it("requests from the network when not in cache", async () => { let called = 0; const inspector = new ApolloLink((operation, forward) => { called++; @@ -228,81 +209,62 @@ describe("no-cache", () => { }); const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), + link: inspector.concat(createLink()), cache: new InMemoryCache({ addTypename: false }), }); - return client - .query({ fetchPolicy: "no-cache", query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect(called).toBe(2); - }) - .then(resolve, reject); + const actualResult = await client.query({ fetchPolicy: "no-cache", query }); + + expect(actualResult.data).toEqual(result); + expect(called).toBe(2); }); - itAsync( - "requests from the network even if already in cache", - (resolve, reject) => { - let called = 0; - const inspector = new ApolloLink((operation, forward) => { + it("requests from the network even if already in cache", async () => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map((result) => { called++; - return forward(operation).map((result) => { - called++; - return result; - }); + return result; }); + }); - const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link: inspector.concat(createLink()), + cache: new InMemoryCache({ addTypename: false }), + }); - return client - .query({ query }) - .then(() => - client - .query({ fetchPolicy: "no-cache", query }) - .then((actualResult) => { - expect(actualResult.data).toEqual(result); - expect(called).toBe(4); - }) - ) - .then(resolve, reject); - } - ); + await client.query({ query }); + const actualResult = await client.query({ fetchPolicy: "no-cache", query }); - itAsync( - "does not save the data to the cache on success", - (resolve, reject) => { - let called = 0; - const inspector = new ApolloLink((operation, forward) => { + expect(actualResult.data).toEqual(result); + expect(called).toBe(4); + }); + + it("does not save the data to the cache on success", async () => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map((result) => { called++; - return forward(operation).map((result) => { - called++; - return result; - }); + return result; }); + }); - const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link: inspector.concat(createLink()), + cache: new InMemoryCache({ addTypename: false }), + }); - return client - .query({ query, fetchPolicy: "no-cache" }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - // the second query couldn't read anything from the cache - expect(called).toBe(4); - }) - ) - .then(resolve, reject); - } - ); + await client.query({ query, fetchPolicy: "no-cache" }); + const actualResult = await client.query({ query }); - itAsync("does not save data to the cache on failure", (resolve, reject) => { + expect(actualResult.data).toEqual(result); + // the second query couldn't read anything from the cache + expect(called).toBe(4); + }); + + it("does not save data to the cache on failure", async () => { let called = 0; const inspector = new ApolloLink((operation, forward) => { called++; @@ -318,24 +280,20 @@ describe("no-cache", () => { }); let didFail = false; - return client - .query({ query, fetchPolicy: "no-cache" }) - .catch((e) => { - expect(e.message).toMatch("query failed"); - didFail = true; - }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - // the first error doesn't call .map on the inspector - expect(called).toBe(3); - expect(didFail).toBe(true); - }) - ) - .then(resolve, reject); + await client.query({ query, fetchPolicy: "no-cache" }).catch((e) => { + expect(e.message).toMatch("query failed"); + didFail = true; + }); + + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + // the first error doesn't call .map on the inspector + expect(called).toBe(3); + expect(didFail).toBe(true); }); - itAsync("does not update the cache on a mutation", (resolve, reject) => { + it("does not update the cache on a mutation", async () => { const inspector = new ApolloLink((operation, forward) => { return forward(operation).map((result) => { return result; @@ -343,59 +301,46 @@ describe("no-cache", () => { }); const client = new ApolloClient({ - link: inspector.concat(createMutationLink(reject)), + link: inspector.concat(createMutationLink()), cache: new InMemoryCache({ addTypename: false }), }); - return client - .query({ query }) - .then(() => - client.mutate({ mutation, variables, fetchPolicy: "no-cache" }) - ) - .then(() => { - return client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - }); - }) - .then(resolve, reject); + await client.query({ query }); + await client.mutate({ mutation, variables, fetchPolicy: "no-cache" }); + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); }); describe("when notifyOnNetworkStatusChange is set", () => { - itAsync( - "does not save the data to the cache on success", - (resolve, reject) => { - let called = 0; - const inspector = new ApolloLink((operation, forward) => { + it("does not save the data to the cache on success", async () => { + let called = 0; + const inspector = new ApolloLink((operation, forward) => { + called++; + return forward(operation).map((result) => { called++; - return forward(operation).map((result) => { - called++; - return result; - }); + return result; }); + }); - const client = new ApolloClient({ - link: inspector.concat(createLink(reject)), - cache: new InMemoryCache({ addTypename: false }), - }); + const client = new ApolloClient({ + link: inspector.concat(createLink()), + cache: new InMemoryCache({ addTypename: false }), + }); - return client - .query({ - query, - fetchPolicy: "no-cache", - notifyOnNetworkStatusChange: true, - }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - // the second query couldn't read anything from the cache - expect(called).toBe(4); - }) - ) - .then(resolve, reject); - } - ); + await client.query({ + query, + fetchPolicy: "no-cache", + notifyOnNetworkStatusChange: true, + }); + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + // the second query couldn't read anything from the cache + expect(called).toBe(4); + }); - itAsync("does not save data to the cache on failure", (resolve, reject) => { + it("does not save data to the cache on failure", async () => { let called = 0; const inspector = new ApolloLink((operation, forward) => { called++; @@ -411,7 +356,7 @@ describe("no-cache", () => { }); let didFail = false; - return client + await client .query({ query, fetchPolicy: "no-cache", @@ -420,16 +365,14 @@ describe("no-cache", () => { .catch((e) => { expect(e.message).toMatch("query failed"); didFail = true; - }) - .then(() => - client.query({ query }).then((actualResult) => { - expect(actualResult.data).toEqual(result); - // the first error doesn't call .map on the inspector - expect(called).toBe(3); - expect(didFail).toBe(true); - }) - ) - .then(resolve, reject); + }); + + const actualResult = await client.query({ query }); + + expect(actualResult.data).toEqual(result); + // the first error doesn't call .map on the inspector + expect(called).toBe(3); + expect(didFail).toBe(true); }); it("gives appropriate networkStatus for watched queries", async () => { @@ -543,11 +486,7 @@ describe("cache-first", () => { results.push(result); return result; }); - }).concat( - createMutationLink((error) => { - throw error; - }) - ), + }).concat(createMutationLink()), cache: new InMemoryCache(), }); diff --git a/src/link/batch-http/__tests__/batchHttpLink.ts b/src/link/batch-http/__tests__/batchHttpLink.ts index 1209b0414c1..033e97a62ac 100644 --- a/src/link/batch-http/__tests__/batchHttpLink.ts +++ b/src/link/batch-http/__tests__/batchHttpLink.ts @@ -10,8 +10,8 @@ import { Observer, } from "../../../utilities/observables/Observable"; import { BatchHttpLink } from "../batchHttpLink"; -import { itAsync } from "../../../testing"; import { FetchResult } from "../../core"; +import { ObservableStream } from "../../../testing/internal"; const sampleQuery = gql` query SampleQuery { @@ -29,22 +29,6 @@ const sampleMutation = gql` } `; -function makeCallback( - resolve: () => void, - reject: (error: Error) => void, - callback: (...args: TArgs) => any -) { - return function () { - try { - // @ts-expect-error - callback.apply(this, arguments); - resolve(); - } catch (error) { - reject(error as Error); - } - } as typeof callback; -} - describe("BatchHttpLink", () => { beforeAll(() => { jest.resetModules(); @@ -76,7 +60,7 @@ describe("BatchHttpLink", () => { expect(() => new BatchHttpLink()).not.toThrow(); }); - itAsync("handles batched requests", (resolve, reject) => { + it("handles batched requests", (done) => { const clientAwareness = { name: "Some Client Name", version: "1.0.1", @@ -91,45 +75,37 @@ describe("BatchHttpLink", () => { let nextCalls = 0; let completions = 0; const next = (expectedData: any) => (data: any) => { - try { - expect(data).toEqual(expectedData); - nextCalls++; - } catch (error) { - reject(error); - } + expect(data).toEqual(expectedData); + nextCalls++; }; const complete = () => { - try { - const calls = fetchMock.calls("begin:/batch"); - expect(calls.length).toBe(1); - expect(nextCalls).toBe(2); + const calls = fetchMock.calls("begin:/batch"); + expect(calls.length).toBe(1); + expect(nextCalls).toBe(2); - const options: any = fetchMock.lastOptions("begin:/batch"); - expect(options.credentials).toEqual("two"); + const options: any = fetchMock.lastOptions("begin:/batch"); + expect(options.credentials).toEqual("two"); - const { headers } = options; - expect(headers["apollographql-client-name"]).toBeDefined(); - expect(headers["apollographql-client-name"]).toEqual( - clientAwareness.name - ); - expect(headers["apollographql-client-version"]).toBeDefined(); - expect(headers["apollographql-client-version"]).toEqual( - clientAwareness.version - ); + const { headers } = options; + expect(headers["apollographql-client-name"]).toBeDefined(); + expect(headers["apollographql-client-name"]).toEqual( + clientAwareness.name + ); + expect(headers["apollographql-client-version"]).toBeDefined(); + expect(headers["apollographql-client-version"]).toEqual( + clientAwareness.version + ); - completions++; + completions++; - if (completions === 2) { - resolve(); - } - } catch (error) { - reject(error); + if (completions === 2) { + done(); } }; const error = (error: any) => { - reject(error); + throw error; }; execute(link, { @@ -146,37 +122,34 @@ describe("BatchHttpLink", () => { }).subscribe(next(data2), error, complete); }); - itAsync( - "errors on an incorrect number of results for a batch", - (resolve, reject) => { - const link = new BatchHttpLink({ - uri: "/batch", - batchInterval: 0, - batchMax: 3, - }); + it("errors on an incorrect number of results for a batch", (done) => { + const link = new BatchHttpLink({ + uri: "/batch", + batchInterval: 0, + batchMax: 3, + }); - let errors = 0; - const next = (data: any) => { - reject("next should not have been called"); - }; + let errors = 0; + const next = (data: any) => { + throw new Error("next should not have been called"); + }; - const complete = () => { - reject("complete should not have been called"); - }; + const complete = () => { + throw new Error("complete should not have been called"); + }; - const error = (error: any) => { - errors++; + const error = (error: any) => { + errors++; - if (errors === 3) { - resolve(); - } - }; + if (errors === 3) { + done(); + } + }; - execute(link, { query: sampleQuery }).subscribe(next, error, complete); - execute(link, { query: sampleQuery }).subscribe(next, error, complete); - execute(link, { query: sampleQuery }).subscribe(next, error, complete); - } - ); + execute(link, { query: sampleQuery }).subscribe(next, error, complete); + execute(link, { query: sampleQuery }).subscribe(next, error, complete); + execute(link, { query: sampleQuery }).subscribe(next, error, complete); + }); describe("batchKey", () => { const query = gql` @@ -188,71 +161,64 @@ describe("BatchHttpLink", () => { } `; - itAsync( - "should batch queries with different options separately", - (resolve, reject) => { - let key = true; - const batchKey = () => { - key = !key; - return "" + !key; - }; + it("should batch queries with different options separately", (done) => { + let key = true; + const batchKey = () => { + key = !key; + return "" + !key; + }; - const link = ApolloLink.from([ - new BatchHttpLink({ - uri: (operation) => { - return operation.variables.endpoint; - }, - batchInterval: 1, - //if batchKey does not work, then the batch size would be 3 - batchMax: 2, - batchKey, - }), - ]); - - let count = 0; - const next = (expected: any) => (received: any) => { - try { - expect(received).toEqual(expected); - } catch (e) { - reject(e); - } - }; - const complete = () => { - count++; - if (count === 4) { - try { - const lawlCalls = fetchMock.calls("begin:/lawl"); - expect(lawlCalls.length).toBe(1); - const roflCalls = fetchMock.calls("begin:/rofl"); - expect(roflCalls.length).toBe(1); - resolve(); - } catch (e) { - reject(e); - } - } - }; + const link = ApolloLink.from([ + new BatchHttpLink({ + uri: (operation) => { + return operation.variables.endpoint; + }, + batchInterval: 1, + //if batchKey does not work, then the batch size would be 3 + batchMax: 2, + batchKey, + }), + ]); - [1, 2].forEach((x) => { - execute(link, { - query, - variables: { endpoint: "/rofl" }, - }).subscribe({ - next: next(roflData), - error: reject, - complete, - }); + let count = 0; + const next = (expected: any) => (received: any) => { + expect(received).toEqual(expected); + }; + const complete = () => { + count++; + if (count === 4) { + const lawlCalls = fetchMock.calls("begin:/lawl"); + expect(lawlCalls.length).toBe(1); + const roflCalls = fetchMock.calls("begin:/rofl"); + expect(roflCalls.length).toBe(1); + done(); + } + }; - execute(link, { - query, - variables: { endpoint: "/lawl" }, - }).subscribe({ - next: next(lawlData), - error: reject, - complete, - }); + [1, 2].forEach((x) => { + execute(link, { + query, + variables: { endpoint: "/rofl" }, + }).subscribe({ + next: next(roflData), + error: (error) => { + throw error; + }, + complete, }); - } - ); + + execute(link, { + query, + variables: { endpoint: "/lawl" }, + }).subscribe({ + next: next(lawlData), + error: (error) => { + throw error; + }, + complete, + }); + }); + }); }); }); @@ -333,127 +299,101 @@ describe("SharedHttpTest", () => { expect(() => createHttpLink()).not.toThrow(); }); - itAsync("calls next and then complete", (resolve, reject) => { - const next = jest.fn(); + it("calls next and then complete", async () => { const link = createHttpLink({ uri: "/data" }); const observable = execute(link, { query: sampleQuery, }); - observable.subscribe({ - next, - error: (error) => reject(error), - complete: makeCallback(resolve, reject, () => { - expect(next).toHaveBeenCalledTimes(1); - }), - }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); - itAsync("calls error when fetch fails", (resolve, reject) => { + it("calls error when fetch fails", async () => { const link = createHttpLink({ uri: "/error" }); const observable = execute(link, { query: sampleQuery, }); - observable.subscribe( - (result) => reject("next should not have been called"), - makeCallback(resolve, reject, (error: any) => { - expect(error).toEqual(mockError.throws); - }), - () => reject("complete should not have been called") - ); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitError(mockError.throws); }); - itAsync("calls error when fetch fails", (resolve, reject) => { + it("calls error when fetch fails", async () => { const link = createHttpLink({ uri: "/error" }); const observable = execute(link, { query: sampleMutation, }); - observable.subscribe( - (result) => reject("next should not have been called"), - makeCallback(resolve, reject, (error: any) => { - expect(error).toEqual(mockError.throws); - }), - () => reject("complete should not have been called") - ); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitError(mockError.throws); }); - itAsync( - "strips unused variables, respecting nested fragments", - (resolve, reject) => { - const link = createHttpLink({ uri: "/data" }); - - const query = gql` - query PEOPLE($declaredAndUsed: String, $declaredButUnused: Int) { - people(surprise: $undeclared, noSurprise: $declaredAndUsed) { - ... on Doctor { - specialty(var: $usedByInlineFragment) - } - ...LawyerFragment + it("strips unused variables, respecting nested fragments", async () => { + const link = createHttpLink({ uri: "/data" }); + + const query = gql` + query PEOPLE($declaredAndUsed: String, $declaredButUnused: Int) { + people(surprise: $undeclared, noSurprise: $declaredAndUsed) { + ... on Doctor { + specialty(var: $usedByInlineFragment) } + ...LawyerFragment } - fragment LawyerFragment on Lawyer { - caseCount(var: $usedByNamedFragment) - } - `; - - const variables = { - unused: "strip", - declaredButUnused: "strip", - declaredAndUsed: "keep", - undeclared: "keep", - usedByInlineFragment: "keep", - usedByNamedFragment: "keep", - }; + } + fragment LawyerFragment on Lawyer { + caseCount(var: $usedByNamedFragment) + } + `; - execute(link, { - query, - variables, - }).subscribe({ - next: makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(JSON.parse(body as string)).toEqual([ - { - operationName: "PEOPLE", - query: print(query), - variables: { - declaredAndUsed: "keep", - undeclared: "keep", - usedByInlineFragment: "keep", - usedByNamedFragment: "keep", - }, - }, - ]); - expect(method).toBe("POST"); - expect(uri).toBe("/data"); - }), - error: (error) => reject(error), - }); - } - ); + const variables = { + unused: "strip", + declaredButUnused: "strip", + declaredAndUsed: "keep", + undeclared: "keep", + usedByInlineFragment: "keep", + usedByNamedFragment: "keep", + }; + + const stream = new ObservableStream(execute(link, { query, variables })); + + await expect(stream).toEmitNext(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + expect(JSON.parse(body as string)).toEqual([ + { + operationName: "PEOPLE", + query: print(query), + variables: { + declaredAndUsed: "keep", + undeclared: "keep", + usedByInlineFragment: "keep", + usedByNamedFragment: "keep", + }, + }, + ]); + expect(method).toBe("POST"); + expect(uri).toBe("/data"); + }); - itAsync("unsubscribes without calling subscriber", (resolve, reject) => { + it("unsubscribes without calling subscriber", async () => { const link = createHttpLink({ uri: "/data" }); const observable = execute(link, { query: sampleQuery, }); - const subscription = observable.subscribe( - (result) => reject("next should not have been called"), - (error) => reject(error), - () => reject("complete should not have been called") - ); - subscription.unsubscribe(); - expect(subscription.closed).toBe(true); - setTimeout(resolve, 50); + const stream = new ObservableStream(observable); + stream.unsubscribe(); + + await expect(stream).not.toEmitAnything(); }); - const verifyRequest = ( + const verifyRequest = async ( link: ApolloLink, - after: () => void, includeExtensions: boolean, - includeUnusedVariables: boolean, - reject: (e: Error) => void + includeUnusedVariables: boolean ) => { - const next = jest.fn(); const context = { info: "stub" }; const variables = { params: "stub" }; @@ -462,61 +402,37 @@ describe("SharedHttpTest", () => { context, variables, }); - observable.subscribe({ - next, - error: (error) => reject(error), - complete: () => { - try { - let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); - expect(body.query).toBe(print(sampleMutation)); - expect(body.variables).toEqual( - includeUnusedVariables ? variables : {} - ); - expect(body.context).not.toBeDefined(); - if (includeExtensions) { - expect(body.extensions).toBeDefined(); - } else { - expect(body.extensions).not.toBeDefined(); - } - expect(next).toHaveBeenCalledTimes(1); - - after(); - } catch (e) { - reject(e as Error); - } - }, - }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); + expect(body.query).toBe(print(sampleMutation)); + expect(body.variables).toEqual(includeUnusedVariables ? variables : {}); + expect(body.context).not.toBeDefined(); + if (includeExtensions) { + expect(body.extensions).toBeDefined(); + } else { + expect(body.extensions).not.toBeDefined(); + } }; - itAsync( - "passes all arguments to multiple fetch body including extensions", - (resolve, reject) => { - const link = createHttpLink({ uri: "/data", includeExtensions: true }); - verifyRequest( - link, - () => verifyRequest(link, resolve, true, false, reject), - true, - false, - reject - ); - } - ); + it("passes all arguments to multiple fetch body including extensions", async () => { + const link = createHttpLink({ uri: "/data", includeExtensions: true }); - itAsync( - "passes all arguments to multiple fetch body excluding extensions", - (resolve, reject) => { - const link = createHttpLink({ uri: "/data" }); - verifyRequest( - link, - () => verifyRequest(link, resolve, false, false, reject), - false, - false, - reject - ); - } - ); + await verifyRequest(link, true, false); + await verifyRequest(link, true, false); + }); + + it("passes all arguments to multiple fetch body excluding extensions", async () => { + const link = createHttpLink({ uri: "/data" }); + + await verifyRequest(link, false, false); + await verifyRequest(link, false, false); + }); - itAsync("calls multiple subscribers", (resolve, reject) => { + it("calls multiple subscribers", (done) => { const link = createHttpLink({ uri: "/data" }); const context = { info: "stub" }; const variables = { params: "stub" }; @@ -536,57 +452,52 @@ describe("SharedHttpTest", () => { // only one call because batchHttpLink can handle more than one subscriber // without starting a new request expect(fetchMock.calls().length).toBe(1); - resolve(); + done(); }, 50); }); - itAsync( - "calls remaining subscribers after unsubscribe", - (resolve, reject) => { - const link = createHttpLink({ uri: "/data" }); - const context = { info: "stub" }; - const variables = { params: "stub" }; + it("calls remaining subscribers after unsubscribe", (done) => { + const link = createHttpLink({ uri: "/data" }); + const context = { info: "stub" }; + const variables = { params: "stub" }; - const observable = execute(link, { - query: sampleMutation, - context, - variables, - }); + const observable = execute(link, { + query: sampleMutation, + context, + variables, + }); - observable.subscribe(subscriber); + observable.subscribe(subscriber); - setTimeout(() => { - const subscription = observable.subscribe(subscriber); - subscription.unsubscribe(); - }, 10); + setTimeout(() => { + const subscription = observable.subscribe(subscriber); + subscription.unsubscribe(); + }, 10); - setTimeout( - makeCallback(resolve, reject, () => { - expect(subscriber.next).toHaveBeenCalledTimes(1); - expect(subscriber.complete).toHaveBeenCalledTimes(1); - expect(subscriber.error).not.toHaveBeenCalled(); - resolve(); - }), - 50 - ); - } - ); + setTimeout(() => { + expect(subscriber.next).toHaveBeenCalledTimes(1); + expect(subscriber.complete).toHaveBeenCalledTimes(1); + expect(subscriber.error).not.toHaveBeenCalled(); + done(); + }, 50); + }); - itAsync("allows for dynamic endpoint setting", (resolve, reject) => { + it("allows for dynamic endpoint setting", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data" }); - execute(link, { - query: sampleQuery, - variables, - context: { uri: "/data2" }, - }).subscribe((result) => { - expect(result).toEqual(data2); - resolve(); - }); + const stream = new ObservableStream( + execute(link, { + query: sampleQuery, + variables, + context: { uri: "/data2" }, + }) + ); + + await expect(stream).toEmitValue(data2); }); - itAsync("adds headers to the request from the context", (resolve, reject) => { + it("adds headers to the request from the context", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -594,43 +505,42 @@ describe("SharedHttpTest", () => { }); return forward(operation).map((result) => { const { headers } = operation.getContext(); - try { - expect(headers).toBeDefined(); - } catch (e) { - reject(e); - } + expect(headers).toBeDefined(); return result; }); }); const link = middleware.concat(createHttpLink({ uri: "/data" })); - - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: Record = fetchMock.lastCall()![1]! - .headers as Record; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const headers: Record = fetchMock.lastCall()![1]! + .headers as Record; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); }); - itAsync("adds headers to the request from the setup", (resolve, reject) => { + it("adds headers to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data", headers: { authorization: "1234" }, }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: Record = fetchMock.lastCall()![1]! - .headers as Record; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const headers: Record = fetchMock.lastCall()![1]! + .headers as Record; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); }); it("uses the latest window.fetch function if options.fetch not configured", (done) => { @@ -688,138 +598,133 @@ describe("SharedHttpTest", () => { ); }); - itAsync( - "prioritizes context headers over setup headers", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - headers: { authorization: "1234" }, - }); - return forward(operation); + it("prioritizes context headers over setup headers", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + headers: { authorization: "1234" }, }); - const link = middleware.concat( - createHttpLink({ uri: "/data", headers: { authorization: "no user" } }) - ); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: "/data", headers: { authorization: "no user" } }) + ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: Record = fetchMock.lastCall()![1]! - .headers as Record; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); - itAsync( - "adds headers to the request from the context on an operation", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ uri: "/data" }); + await expect(stream).toEmitNext(); - const context = { - headers: { authorization: "1234" }, - }; + const headers: Record = fetchMock.lastCall()![1]! + .headers as Record; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("adds headers to the request from the context on an operation", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ uri: "/data" }); + + const context = { + headers: { authorization: "1234" }, + }; + const stream = new ObservableStream( execute(link, { query: sampleQuery, variables, context, - }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: Record = fetchMock.lastCall()![1]! - .headers as Record; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + }) + ); - itAsync( - "adds headers w/ preserved case to the request from the setup", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - headers: { - authorization: "1234", - AUTHORIZATION: "1234", - "CONTENT-TYPE": "application/json", - }, - preserveHeaderCase: true, - }); + await expect(stream).toEmitNext(); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: any = fetchMock.lastCall()![1]!.headers; - expect(headers.AUTHORIZATION).toBe("1234"); - expect(headers["CONTENT-TYPE"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); - - itAsync( - "prioritizes context headers w/ preserved case over setup headers", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - headers: { AUTHORIZATION: "1234" }, - http: { preserveHeaderCase: true }, - }); - return forward(operation); - }); - const link = middleware.concat( - createHttpLink({ - uri: "/data", - headers: { authorization: "no user" }, - preserveHeaderCase: false, - }) - ); + const headers: Record = fetchMock.lastCall()![1]! + .headers as Record; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: any = fetchMock.lastCall()![1]!.headers; - expect(headers.AUTHORIZATION).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + it("adds headers w/ preserved case to the request from the setup", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + headers: { + authorization: "1234", + AUTHORIZATION: "1234", + "CONTENT-TYPE": "application/json", + }, + preserveHeaderCase: true, + }); - itAsync( - "adds headers w/ preserved case to the request from the context on an operation", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ uri: "/data" }); + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); + + await expect(stream).toEmitNext(); - const context = { + const headers: any = fetchMock.lastCall()![1]!.headers; + expect(headers.AUTHORIZATION).toBe("1234"); + expect(headers["CONTENT-TYPE"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("prioritizes context headers w/ preserved case over setup headers", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ headers: { AUTHORIZATION: "1234" }, http: { preserveHeaderCase: true }, - }; + }); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ + uri: "/data", + headers: { authorization: "no user" }, + preserveHeaderCase: false, + }) + ); + + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); + + await expect(stream).toEmitNext(); + + const headers: any = fetchMock.lastCall()![1]!.headers; + expect(headers.AUTHORIZATION).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("adds headers w/ preserved case to the request from the context on an operation", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ uri: "/data" }); + + const context = { + headers: { AUTHORIZATION: "1234" }, + http: { preserveHeaderCase: true }, + }; + const stream = new ObservableStream( execute(link, { query: sampleQuery, variables, context, - }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const headers: any = fetchMock.lastCall()![1]!.headers; - expect(headers.AUTHORIZATION).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + }) + ); + + await expect(stream).toEmitNext(); - itAsync("adds creds to the request from the context", (resolve, reject) => { + const headers: any = fetchMock.lastCall()![1]!.headers; + expect(headers.AUTHORIZATION).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("adds creds to the request from the context", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -829,50 +734,53 @@ describe("SharedHttpTest", () => { }); const link = middleware.concat(createHttpLink({ uri: "/data" })); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); }); - itAsync("adds creds to the request from the setup", (resolve, reject) => { + it("adds creds to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data", credentials: "same-team-yo" }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); }); - itAsync( - "prioritizes creds from the context over the setup", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - credentials: "same-team-yo", - }); - return forward(operation); + it("prioritizes creds from the context over the setup", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + credentials: "same-team-yo", }); - const link = middleware.concat( - createHttpLink({ uri: "/data", credentials: "error" }) - ); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: "/data", credentials: "error" }) + ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) - ); - } - ); + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); + + await expect(stream).toEmitNext(); + + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); + }); - itAsync("adds uri to the request from the context", (resolve, reject) => { + it("adds uri to the request from the context", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -882,27 +790,31 @@ describe("SharedHttpTest", () => { }); const link = middleware.concat(createHttpLink()); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const uri = fetchMock.lastUrl(); - expect(uri).toBe("/data"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/data"); }); - itAsync("adds uri to the request from the setup", (resolve, reject) => { + it("adds uri to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data" }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const uri = fetchMock.lastUrl(); - expect(uri).toBe("/data"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/data"); }); - itAsync("prioritizes context uri over setup uri", (resolve, reject) => { + it("prioritizes context uri over setup uri", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -914,82 +826,77 @@ describe("SharedHttpTest", () => { createHttpLink({ uri: "/data", credentials: "error" }) ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const uri = fetchMock.lastUrl(); - - expect(uri).toBe("/apollo"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/apollo"); }); - itAsync("allows uri to be a function", (resolve, reject) => { + it("allows uri to be a function", async () => { const variables = { params: "stub" }; const customFetch = (_uri: any, options: any) => { const { operationName } = convertBatchedBody(options.body); - try { - expect(operationName).toBe("SampleQuery"); - } catch (e) { - reject(e); - } + expect(operationName).toBe("SampleQuery"); return fetch("/dataFunc", options); }; const link = createHttpLink({ fetch: customFetch }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - expect(fetchMock.lastUrl()).toBe("/dataFunc"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + expect(fetchMock.lastUrl()).toBe("/dataFunc"); }); - itAsync( - "adds fetchOptions to the request from the setup", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - fetchOptions: { someOption: "foo", mode: "no-cors" }, - }); + it("adds fetchOptions to the request from the setup", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + fetchOptions: { someOption: "foo", mode: "no-cors" }, + }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const { someOption, mode, headers } = - fetchMock.lastCall()![1]! as any; - expect(someOption).toBe("foo"); - expect(mode).toBe("no-cors"); - expect(headers["content-type"]).toBe("application/json"); - }) - ); - } - ); - - itAsync( - "adds fetchOptions to the request from the context", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - fetchOptions: { - someOption: "foo", - }, - }); - return forward(operation); + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); + + await expect(stream).toEmitNext(); + + const { someOption, mode, headers } = fetchMock.lastCall()![1]! as any; + expect(someOption).toBe("foo"); + expect(mode).toBe("no-cors"); + expect(headers["content-type"]).toBe("application/json"); + }); + + it("adds fetchOptions to the request from the context", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + fetchOptions: { + someOption: "foo", + }, }); - const link = middleware.concat(createHttpLink({ uri: "/data" })); + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: "/data" })); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const { someOption } = fetchMock.lastCall()![1]! as any; - expect(someOption).toBe("foo"); - resolve(); - }) - ); - } - ); + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); - itAsync("uses the print option function when defined", (resolve, reject) => { + await expect(stream).toEmitNext(); + + const { someOption } = fetchMock.lastCall()![1]! as any; + expect(someOption).toBe("foo"); + }); + + it("uses the print option function when defined", async () => { const customPrinter = jest.fn( (ast: ASTNode, originalPrint: typeof print) => { return stripIgnoredCharacters(originalPrint(ast)); @@ -998,16 +905,16 @@ describe("SharedHttpTest", () => { const httpLink = createHttpLink({ uri: "data", print: customPrinter }); - execute(httpLink, { - query: sampleQuery, - }).subscribe( - makeCallback(resolve, reject, () => { - expect(customPrinter).toHaveBeenCalledTimes(1); - }) + const stream = new ObservableStream( + execute(httpLink, { query: sampleQuery }) ); + + await expect(stream).toEmitNext(); + + expect(customPrinter).toHaveBeenCalledTimes(1); }); - itAsync("prioritizes context over setup", (resolve, reject) => { + it("prioritizes context over setup", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -1021,53 +928,53 @@ describe("SharedHttpTest", () => { createHttpLink({ uri: "/data", fetchOptions: { someOption: "bar" } }) ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - const { someOption } = fetchMock.lastCall()![1]! as any; - expect(someOption).toBe("foo"); - }) + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) ); + + await expect(stream).toEmitNext(); + + const { someOption } = fetchMock.lastCall()![1]! as any; + expect(someOption).toBe("foo"); }); - itAsync( - "allows for not sending the query with the request", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - http: { - includeQuery: false, - includeExtensions: true, - }, - }); - operation.extensions.persistedQuery = { hash: "1234" }; - return forward(operation); + it("allows for not sending the query with the request", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + http: { + includeQuery: false, + includeExtensions: true, + }, }); - const link = middleware.concat(createHttpLink({ uri: "/data" })); + operation.extensions.persistedQuery = { hash: "1234" }; + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: "/data" })); + + const stream = new ObservableStream( + execute(link, { query: sampleQuery, variables }) + ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, (result: any) => { - let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); + await expect(stream).toEmitNext(); - expect(body.query).not.toBeDefined(); - expect(body.extensions).toEqual({ persistedQuery: { hash: "1234" } }); - resolve(); - }) - ); - } - ); + let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); + + expect(body.query).not.toBeDefined(); + expect(body.extensions).toEqual({ persistedQuery: { hash: "1234" } }); + }); - itAsync("sets the raw response on context", (resolve, reject) => { + it("sets the raw response on context", async () => { const middleware = new ApolloLink((operation, forward) => { return new Observable((ob) => { const op = forward(operation); const sub = op.subscribe({ next: ob.next.bind(ob), error: ob.error.bind(ob), - complete: makeCallback(resolve, reject, () => { + complete: () => { expect(operation.getContext().response.headers.toBeDefined); ob.complete(); - }), + }, }); return () => { @@ -1078,12 +985,10 @@ describe("SharedHttpTest", () => { const link = middleware.concat(createHttpLink({ uri: "/data", fetch })); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - resolve(); - }, - () => {} - ); + const stream = new ObservableStream(execute(link, { query: sampleQuery })); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); it("removes @client fields from the query before sending it to the server", async () => { diff --git a/src/link/batch/__tests__/batchLink.ts b/src/link/batch/__tests__/batchLink.ts index e5930924c27..ff5aa72edd0 100644 --- a/src/link/batch/__tests__/batchLink.ts +++ b/src/link/batch/__tests__/batchLink.ts @@ -4,13 +4,14 @@ import { print } from "graphql"; import { ApolloLink, execute } from "../../core"; import { Operation, FetchResult, GraphQLRequest } from "../../core/types"; import { Observable } from "../../../utilities"; -import { itAsync } from "../../../testing"; +import { wait } from "../../../testing"; import { BatchLink, OperationBatcher, BatchHandler, BatchableRequest, } from "../batchLink"; +import { ObservableStream } from "../../../testing/internal"; interface MockedResponse { request: GraphQLRequest; @@ -57,22 +58,6 @@ function createOperation(starting: any, operation: GraphQLRequest): Operation { return operation as Operation; } -function terminatingCheck( - resolve: () => any, - reject: (e: any) => any, - callback: (...args: TArgs) => any -) { - return function () { - try { - // @ts-expect-error - callback.apply(this, arguments); - resolve(); - } catch (error) { - reject(error); - } - } as typeof callback; -} - function requestToKey(request: GraphQLRequest): string { const queryString = typeof request.query === "string" ? request.query : print(request.query); @@ -221,195 +206,129 @@ describe("OperationBatcher", () => { } ); - itAsync( - "should be able to consume from a queue containing a single query", - (resolve, reject) => { - const myBatcher = new OperationBatcher({ - batchInterval: 10, - batchHandler, - }); + it("should be able to consume from a queue containing a single query", async () => { + const myBatcher = new OperationBatcher({ + batchInterval: 10, + batchHandler, + }); + + const observable = myBatcher.enqueueRequest({ operation }); + const stream = new ObservableStream(observable); + + const observables: (Observable | undefined)[] = + myBatcher.consumeQueue()!; - myBatcher.enqueueRequest({ operation }).subscribe( - terminatingCheck(resolve, reject, (resultObj: any) => { - expect(myBatcher["batchesByKey"].get("")).toBeUndefined(); - expect(resultObj).toEqual({ data }); - }) - ); - const observables: (Observable | undefined)[] = - myBatcher.consumeQueue()!; - - try { - expect(observables.length).toBe(1); - } catch (e) { - reject(e); + expect(observables.length).toBe(1); + expect(myBatcher["batchesByKey"].get("")).toBeUndefined(); + + await expect(stream).toEmitValue({ data }); + }); + + it("should be able to consume from a queue containing multiple queries", async () => { + const request2: Operation = createOperation( + {}, + { + query, } - } - ); + ); - itAsync( - "should be able to consume from a queue containing multiple queries", - (resolve, reject) => { - const request2: Operation = createOperation( - {}, - { - query, - } - ); + const BH = createMockBatchHandler( + { + request: { query }, + result: { data }, + }, + { + request: { query }, + result: { data }, + } + ); - const BH = createMockBatchHandler( - { - request: { query }, - result: { data }, - }, - { - request: { query }, - result: { data }, - } - ); + const myBatcher = new OperationBatcher({ + batchInterval: 10, + batchMax: 10, + batchHandler: BH, + }); + const observable1 = myBatcher.enqueueRequest({ operation }); + const observable2 = myBatcher.enqueueRequest({ operation: request2 }); - const myBatcher = new OperationBatcher({ - batchInterval: 10, - batchMax: 10, - batchHandler: BH, - }); - const observable1 = myBatcher.enqueueRequest({ operation }); - const observable2 = myBatcher.enqueueRequest({ operation: request2 }); - let notify = false; - observable1.subscribe((resultObj1) => { - try { - expect(resultObj1).toEqual({ data }); - } catch (e) { - reject(e); - } + const stream1 = new ObservableStream(observable1); + const stream2 = new ObservableStream(observable2); - if (notify) { - resolve(); - } else { - notify = true; - } - }); + expect(myBatcher["batchesByKey"].get("")!.size).toBe(2); + const observables: (Observable | undefined)[] = + myBatcher.consumeQueue()!; + expect(myBatcher["batchesByKey"].get("")).toBeUndefined(); + expect(observables.length).toBe(2); - observable2.subscribe((resultObj2) => { - try { - expect(resultObj2).toEqual({ data }); - } catch (e) { - reject(e); - } + await expect(stream1).toEmitValue({ data }); + await expect(stream2).toEmitValue({ data }); + }); - if (notify) { - resolve(); - } else { - notify = true; - } - }); + it("should be able to consume from a queue containing multiple queries with different batch keys", async () => { + // NOTE: this test was added to ensure that queries don't "hang" when consumed by BatchLink. + // "Hanging" in this case results in this test never resolving. So + // if this test times out it's probably a real issue and not a flake + const request2: Operation = createOperation( + {}, + { + query, + } + ); - try { - expect(myBatcher["batchesByKey"].get("")!.size).toBe(2); - const observables: (Observable | undefined)[] = - myBatcher.consumeQueue()!; - expect(myBatcher["batchesByKey"].get("")).toBeUndefined(); - expect(observables.length).toBe(2); - } catch (e) { - reject(e); + const BH = createMockBatchHandler( + { + request: { query }, + result: { data }, + }, + { + request: { query }, + result: { data }, } - } - ); + ); - itAsync( - "should be able to consume from a queue containing multiple queries with different batch keys", - (resolve, reject) => { - // NOTE: this test was added to ensure that queries don't "hang" when consumed by BatchLink. - // "Hanging" in this case results in this test never resolving. So - // if this test times out it's probably a real issue and not a flake - const request2: Operation = createOperation( - {}, - { - query, - } - ); + let key = true; + const batchKey = () => { + key = !key; + return "" + !key; + }; - const BH = createMockBatchHandler( - { - request: { query }, - result: { data }, - }, - { - request: { query }, - result: { data }, - } - ); - - let key = true; - const batchKey = () => { - key = !key; - return "" + !key; - }; - - const myBatcher = new OperationBatcher({ - batchInterval: 10, - batchMax: 10, - batchHandler: BH, - batchKey, - }); + const myBatcher = new OperationBatcher({ + batchInterval: 10, + batchMax: 10, + batchHandler: BH, + batchKey, + }); - const observable1 = myBatcher.enqueueRequest({ operation }); - const observable2 = myBatcher.enqueueRequest({ operation: request2 }); + const observable1 = myBatcher.enqueueRequest({ operation }); + const observable2 = myBatcher.enqueueRequest({ operation: request2 }); - let notify = false; - observable1.subscribe((resultObj1) => { - try { - expect(resultObj1).toEqual({ data }); - } catch (e) { - reject(e); - } + const stream1 = new ObservableStream(observable1); + const stream2 = new ObservableStream(observable2); - if (notify) { - resolve(); - } else { - notify = true; - } - }); + jest.runAllTimers(); - observable2.subscribe((resultObj2) => { - try { - expect(resultObj2).toEqual({ data }); - } catch (e) { - reject(e); - } + await expect(stream1).toEmitValue({ data }); + await expect(stream2).toEmitValue({ data }); + }); - if (notify) { - resolve(); - } else { - notify = true; - } - }); + it("should return a promise when we enqueue a request and resolve it with a result", async () => { + const BH = createMockBatchHandler({ + request: { query }, + result: { data }, + }); + const myBatcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: BH, + }); + const observable = myBatcher.enqueueRequest({ operation }); + const stream = new ObservableStream(observable); - jest.runAllTimers(); - } - ); + myBatcher.consumeQueue(); - itAsync( - "should return a promise when we enqueue a request and resolve it with a result", - (resolve, reject) => { - const BH = createMockBatchHandler({ - request: { query }, - result: { data }, - }); - const myBatcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: BH, - }); - const observable = myBatcher.enqueueRequest({ operation }); - observable.subscribe( - terminatingCheck(resolve, reject, (result: any) => { - expect(result).toEqual({ data }); - }) - ); - myBatcher.consumeQueue(); - } - ); + await expect(stream).toEmitValue({ data }); + }); - itAsync("should be able to debounce requests", (resolve, reject) => { + it("should be able to debounce requests", () => { const batchInterval = 10; const myBatcher = new OperationBatcher({ batchDebounce: true, @@ -442,11 +361,10 @@ describe("OperationBatcher", () => { // and expect the queue to be empty. jest.advanceTimersByTime(batchInterval / 2); expect(myBatcher["batchesByKey"].size).toEqual(0); - resolve(); }); }); - itAsync("should work when single query", (resolve, reject) => { + it("should work when single query", async () => { const data = { lastName: "Ever", firstName: "Greatest", @@ -470,152 +388,138 @@ describe("OperationBatcher", () => { const operation: Operation = createOperation({}, { query }); batcher.enqueueRequest({ operation }).subscribe({}); - try { - expect(batcher["batchesByKey"].get("")!.size).toBe(1); - } catch (e) { - reject(e); - } - - setTimeout( - terminatingCheck(resolve, reject, () => { - expect(batcher["batchesByKey"].get("")).toBeUndefined(); - }), - 20 - ); + expect(batcher["batchesByKey"].get("")!.size).toBe(1); + const promise = wait(20); jest.runAllTimers(); + await promise; + + expect(batcher["batchesByKey"].get("")).toBeUndefined(); }); - itAsync( - "should cancel single query in queue when unsubscribing", - (resolve, reject) => { - const data = { - lastName: "Ever", - firstName: "Greatest", - }; + it("should cancel single query in queue when unsubscribing", async () => { + const data = { + lastName: "Ever", + firstName: "Greatest", + }; - const batcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: () => - new Observable((observer) => { - observer.next([{ data }]); - setTimeout(observer.complete.bind(observer)); - }), - }); + const batcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: () => + new Observable((observer) => { + observer.next([{ data }]); + setTimeout(observer.complete.bind(observer)); + }), + }); - const query = gql` - query { - author { - firstName - lastName - } + const query = gql` + query { + author { + firstName + lastName } - `; + } + `; - batcher - .enqueueRequest({ - operation: createOperation({}, { query }), - }) - .subscribe(() => reject("next should never be called")) - .unsubscribe(); + batcher + .enqueueRequest({ + operation: createOperation({}, { query }), + }) + .subscribe(() => { + throw new Error("next should never be called"); + }) + .unsubscribe(); - expect(batcher["batchesByKey"].get("")).toBeUndefined(); - resolve(); - } - ); - - itAsync( - "should cancel single query in queue with multiple subscriptions", - (resolve, reject) => { - const data = { - lastName: "Ever", - firstName: "Greatest", - }; - const batcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: () => - new Observable((observer) => { - observer.next([{ data }]); - setTimeout(observer.complete.bind(observer)); - }), - }); - const query = gql` - query { - author { - firstName - lastName - } + expect(batcher["batchesByKey"].get("")).toBeUndefined(); + }); + + it("should cancel single query in queue with multiple subscriptions", () => { + const data = { + lastName: "Ever", + firstName: "Greatest", + }; + const batcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: () => + new Observable((observer) => { + observer.next([{ data }]); + setTimeout(observer.complete.bind(observer)); + }), + }); + const query = gql` + query { + author { + firstName + lastName } - `; - const operation: Operation = createOperation({}, { query }); + } + `; + const operation: Operation = createOperation({}, { query }); - const observable = batcher.enqueueRequest({ operation }); + const observable = batcher.enqueueRequest({ operation }); - const checkQueuedRequests = (expectedSubscriberCount: number) => { - const batch = batcher["batchesByKey"].get(""); - expect(batch).not.toBeUndefined(); - expect(batch!.size).toBe(1); - batch!.forEach((request) => { - expect(request.subscribers.size).toBe(expectedSubscriberCount); - }); - }; + const checkQueuedRequests = (expectedSubscriberCount: number) => { + const batch = batcher["batchesByKey"].get(""); + expect(batch).not.toBeUndefined(); + expect(batch!.size).toBe(1); + batch!.forEach((request) => { + expect(request.subscribers.size).toBe(expectedSubscriberCount); + }); + }; - const sub1 = observable.subscribe(() => - reject("next should never be called") - ); - checkQueuedRequests(1); + const sub1 = observable.subscribe(() => { + throw new Error("next should never be called"); + }); + checkQueuedRequests(1); - const sub2 = observable.subscribe(() => - reject("next should never be called") - ); - checkQueuedRequests(2); + const sub2 = observable.subscribe(() => { + throw new Error("next should never be called"); + }); + checkQueuedRequests(2); - sub1.unsubscribe(); - checkQueuedRequests(1); + sub1.unsubscribe(); + checkQueuedRequests(1); - sub2.unsubscribe(); - expect(batcher["batchesByKey"].get("")).toBeUndefined(); - resolve(); - } - ); + sub2.unsubscribe(); + expect(batcher["batchesByKey"].get("")).toBeUndefined(); + }); - itAsync( - "should cancel single query in flight when unsubscribing", - (resolve, reject) => { - const batcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: () => - new Observable(() => { - // Instead of typically starting an XHR, we trigger the unsubscription from outside - setTimeout(() => subscription?.unsubscribe(), 5); - - return () => { - expect(batcher["batchesByKey"].get("")).toBeUndefined(); - resolve(); - }; - }), - }); + it("should cancel single query in flight when unsubscribing", (done) => { + const batcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: () => + new Observable(() => { + // Instead of typically starting an XHR, we trigger the unsubscription from outside + setTimeout(() => subscription?.unsubscribe(), 5); + + return () => { + expect(batcher["batchesByKey"].get("")).toBeUndefined(); + done(); + }; + }), + }); - const query = gql` - query { - author { - firstName - lastName - } + const query = gql` + query { + author { + firstName + lastName } - `; + } + `; - const subscription = batcher - .enqueueRequest({ - operation: createOperation({}, { query }), - }) - .subscribe(() => reject("next should never be called")); + const subscription = batcher + .enqueueRequest({ + operation: createOperation({}, { query }), + }) + .subscribe(() => { + throw new Error("next should never be called"); + }); - jest.runAllTimers(); - } - ); + jest.runAllTimers(); + }); - itAsync("should correctly batch multiple queries", (resolve, reject) => { + it("should correctly batch multiple queries", async () => { const data = { lastName: "Ever", firstName: "Greatest", @@ -646,126 +550,109 @@ describe("OperationBatcher", () => { batcher.enqueueRequest({ operation }).subscribe({}); batcher.enqueueRequest({ operation: operation2 }).subscribe({}); - try { - expect(batcher["batchesByKey"].get("")!.size).toBe(2); - } catch (e) { - reject(e); - } + expect(batcher["batchesByKey"].get("")!.size).toBe(2); setTimeout(() => { // The batch shouldn't be fired yet, so we can add one more request. batcher.enqueueRequest({ operation: operation3 }).subscribe({}); - try { - expect(batcher["batchesByKey"].get("")!.size).toBe(3); - } catch (e) { - reject(e); - } + expect(batcher["batchesByKey"].get("")!.size).toBe(3); }, 5); - setTimeout( - terminatingCheck(resolve, reject, () => { - // The batch should've been fired by now. - expect(batcher["batchesByKey"].get("")).toBeUndefined(); - }), - 20 - ); - + const promise = wait(20); jest.runAllTimers(); + await promise; + + // The batch should've been fired by now. + expect(batcher["batchesByKey"].get("")).toBeUndefined(); }); - itAsync( - "should cancel multiple queries in queue when unsubscribing and let pass still subscribed one", - (resolve, reject) => { - const data2 = { - lastName: "Hauser", - firstName: "Evans", - }; + it("should cancel multiple queries in queue when unsubscribing and let pass still subscribed one", (done) => { + const data2 = { + lastName: "Hauser", + firstName: "Evans", + }; - const batcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: () => - new Observable((observer) => { - observer.next([{ data: data2 }]); - setTimeout(observer.complete.bind(observer)); - }), - }); + const batcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: () => + new Observable((observer) => { + observer.next([{ data: data2 }]); + setTimeout(observer.complete.bind(observer)); + }), + }); - const query = gql` - query { - author { - firstName - lastName - } + const query = gql` + query { + author { + firstName + lastName } - `; + } + `; + + const operation: Operation = createOperation({}, { query }); + const operation2: Operation = createOperation({}, { query }); + const operation3: Operation = createOperation({}, { query }); - const operation: Operation = createOperation({}, { query }); - const operation2: Operation = createOperation({}, { query }); - const operation3: Operation = createOperation({}, { query }); + const sub1 = batcher.enqueueRequest({ operation }).subscribe(() => { + throw new Error("next should never be called"); + }); + batcher.enqueueRequest({ operation: operation2 }).subscribe((result) => { + expect(result.data).toBe(data2); - const sub1 = batcher - .enqueueRequest({ operation }) - .subscribe(() => reject("next should never be called")); - batcher.enqueueRequest({ operation: operation2 }).subscribe((result) => { - expect(result.data).toBe(data2); + // The batch should've been fired by now. + expect(batcher["batchesByKey"].get("")).toBeUndefined(); - // The batch should've been fired by now. - expect(batcher["batchesByKey"].get("")).toBeUndefined(); + done(); + }); - resolve(); - }); + expect(batcher["batchesByKey"].get("")!.size).toBe(2); + sub1.unsubscribe(); + expect(batcher["batchesByKey"].get("")!.size).toBe(1); + + setTimeout(() => { + // The batch shouldn't be fired yet, so we can add one more request. + const sub3 = batcher + .enqueueRequest({ operation: operation3 }) + .subscribe(() => { + throw new Error("next should never be called"); + }); expect(batcher["batchesByKey"].get("")!.size).toBe(2); - sub1.unsubscribe(); + sub3.unsubscribe(); expect(batcher["batchesByKey"].get("")!.size).toBe(1); + }, 5); - setTimeout(() => { - // The batch shouldn't be fired yet, so we can add one more request. - const sub3 = batcher - .enqueueRequest({ operation: operation3 }) - .subscribe(() => reject("next should never be called")); - expect(batcher["batchesByKey"].get("")!.size).toBe(2); - - sub3.unsubscribe(); - expect(batcher["batchesByKey"].get("")!.size).toBe(1); - }, 5); + jest.runAllTimers(); + }); - jest.runAllTimers(); - } - ); - - itAsync( - "should reject the promise if there is a network error", - (resolve, reject) => { - const query = gql` - query { - author { - firstName - lastName - } + it("should reject the promise if there is a network error", async () => { + const query = gql` + query { + author { + firstName + lastName } - `; - const operation: Operation = createOperation({}, { query }); - const error = new Error("Network error"); - const BH = createMockBatchHandler({ - request: { query }, - error, - }); - const batcher = new OperationBatcher({ - batchInterval: 10, - batchHandler: BH, - }); + } + `; + const operation: Operation = createOperation({}, { query }); + const error = new Error("Network error"); + const BH = createMockBatchHandler({ + request: { query }, + error, + }); + const batcher = new OperationBatcher({ + batchInterval: 10, + batchHandler: BH, + }); - const observable = batcher.enqueueRequest({ operation }); - observable.subscribe({ - error: terminatingCheck(resolve, reject, (resError: Error) => { - expect(resError.message).toBe("Network error"); - }), - }); - batcher.consumeQueue(); - } - ); + const observable = batcher.enqueueRequest({ operation }); + const stream = new ObservableStream(observable); + batcher.consumeQueue(); + + await expect(stream).toEmitError(error); + }); }); describe("BatchLink", () => { @@ -781,25 +668,21 @@ describe("BatchLink", () => { ).not.toThrow(); }); - itAsync("passes forward on", (resolve, reject) => { + it("passes forward on", async () => { + expect.assertions(3); const link = ApolloLink.from([ new BatchLink({ batchInterval: 0, batchMax: 1, batchHandler: (operation, forward) => { - try { - expect(forward!.length).toBe(1); - expect(operation.length).toBe(1); - } catch (e) { - reject(e); - } + expect(forward!.length).toBe(1); + expect(operation.length).toBe(1); + return forward![0]!(operation[0]).map((result) => [result]); }, }), new ApolloLink((operation) => { - terminatingCheck(resolve, reject, () => { - expect(operation.query).toEqual(query); - })(); + expect(operation.query).toEqual(query); return null; }), ]); @@ -812,7 +695,7 @@ describe("BatchLink", () => { query, } ) - ).subscribe((result) => reject()); + ).subscribe(() => {}); }); it("raises warning if terminating", () => { @@ -849,28 +732,17 @@ describe("BatchLink", () => { expect(calls).toBe(2); }); - itAsync("correctly uses batch size", (resolve, reject) => { + it("correctly uses batch size", async () => { const sizes = [1, 2, 3]; const terminating = new ApolloLink((operation) => { - try { - expect(operation.query).toEqual(query); - } catch (e) { - reject(e); - } + expect(operation.query).toEqual(query); return Observable.of(operation.variables.count); }); - let runBatchSize = () => { - const size = sizes.pop(); - if (!size) resolve(); - + let runBatchSize = async (size: number) => { const batchHandler = jest.fn((operation, forward) => { - try { - expect(operation.length).toBe(size); - expect(forward.length).toBe(size); - } catch (e) { - reject(e); - } + expect(operation.length).toBe(size); + expect(forward.length).toBe(size); const observables = forward.map((f: any, i: any) => f(operation[i])); return new Observable((observer) => { const data: any[] = []; @@ -895,45 +767,43 @@ describe("BatchLink", () => { terminating, ]); - Array.from(new Array(size)).forEach((_, i) => { - execute(link, { - query, - variables: { count: i }, - }).subscribe({ - next: (data) => { - expect(data).toBe(i); - }, - complete: () => { - try { - expect(batchHandler.mock.calls.length).toBe(1); - } catch (e) { - reject(e); - } - runBatchSize(); - }, - }); - }); + return Promise.all( + Array.from(new Array(size)).map((_, i) => { + return new Promise((resolve) => { + execute(link, { + query, + variables: { count: i }, + }).subscribe({ + next: (data) => { + expect(data).toBe(i); + }, + complete: () => { + expect(batchHandler.mock.calls.length).toBe(1); + resolve(); + }, + }); + }); + }) + ); }; - runBatchSize(); + for (const size of sizes) { + await runBatchSize(size); + } }); - itAsync("correctly follows batch interval", (resolve, reject) => { + it("correctly follows batch interval", (done) => { const intervals = [10, 20, 30]; const runBatchInterval = () => { const mock = jest.fn(); const batchInterval = intervals.pop(); - if (!batchInterval) return resolve(); + if (!batchInterval) return done(); const batchHandler = jest.fn((operation, forward) => { - try { - expect(operation.length).toBe(1); - expect(forward.length).toBe(1); - } catch (e) { - reject(e); - } + expect(operation.length).toBe(1); + expect(forward.length).toBe(1); return forward[0](operation[0]).map((d: any) => [d]); }); @@ -957,11 +827,7 @@ describe("BatchLink", () => { ) ).subscribe({ next: (data) => { - try { - expect(data).toBe(42); - } catch (e) { - reject(e); - } + expect(data).toBe(42); }, complete: () => { mock(batchHandler.mock.calls.length); @@ -972,19 +838,15 @@ describe("BatchLink", () => { await delay(batchInterval); const checkCalls = mock.mock.calls.slice(0, -1); - try { - expect(checkCalls.length).toBe(2); - checkCalls.forEach((args) => expect(args[0]).toBe(0)); - expect(mock).lastCalledWith(1); - expect(batchHandler.mock.calls.length).toBe(1); - } catch (e) { - reject(e); - } + expect(checkCalls.length).toBe(2); + checkCalls.forEach((args) => expect(args[0]).toBe(0)); + expect(mock).lastCalledWith(1); + expect(batchHandler.mock.calls.length).toBe(1); runBatchInterval(); }; - delayedBatchInterval(); + void delayedBatchInterval(); mock(batchHandler.mock.calls.length); mock(batchHandler.mock.calls.length); @@ -994,97 +856,82 @@ describe("BatchLink", () => { runBatchInterval(); }); - itAsync( - "throws an error when more requests than results", - (resolve, reject) => { - const result = [{ data: {} }]; - const batchHandler = jest.fn((op) => Observable.of(result)); + it("throws an error when more requests than results", () => { + expect.assertions(4); + const result = [{ data: {} }]; + const batchHandler = jest.fn((op) => Observable.of(result)); + + const link = ApolloLink.from([ + new BatchLink({ + batchInterval: 10, + batchMax: 2, + batchHandler, + }), + ]); + + [1, 2].forEach((x) => { + execute(link, { + query, + }).subscribe({ + next: (data) => { + throw new Error("next should not be called"); + }, + error: (error: any) => { + expect(error).toBeDefined(); + expect(error.result).toEqual(result); + }, + complete: () => { + throw new Error("complete should not be called"); + }, + }); + }); + }); + + describe("batchKey", () => { + it("should allow different batches to be created separately", (done) => { + const data = { data: {} }; + const result = [data, data]; + + const batchHandler = jest.fn((op) => { + expect(op.length).toBe(2); + return Observable.of(result); + }); + let key = true; + const batchKey = () => { + key = !key; + return "" + !key; + }; const link = ApolloLink.from([ new BatchLink({ - batchInterval: 10, + batchInterval: 1, + //if batchKey does not work, then the batch size would be 3 batchMax: 2, batchHandler, + batchKey, }), ]); - [1, 2].forEach((x) => { + let count = 0; + [1, 2, 3, 4].forEach(() => { execute(link, { query, }).subscribe({ - next: (data) => { - reject("next should not be called"); + next: (d) => { + expect(d).toEqual(data); + }, + error: (e) => { + throw e; }, - error: terminatingCheck(resolve, reject, (error: any) => { - expect(error).toBeDefined(); - expect(error.result).toEqual(result); - }), complete: () => { - reject("complete should not be called"); + count++; + if (count === 4) { + expect(batchHandler.mock.calls.length).toBe(2); + done(); + } }, }); }); - } - ); - - describe("batchKey", () => { - itAsync( - "should allow different batches to be created separately", - (resolve, reject) => { - const data = { data: {} }; - const result = [data, data]; - - const batchHandler = jest.fn((op) => { - try { - expect(op.length).toBe(2); - } catch (e) { - reject(e); - } - return Observable.of(result); - }); - let key = true; - const batchKey = () => { - key = !key; - return "" + !key; - }; - - const link = ApolloLink.from([ - new BatchLink({ - batchInterval: 1, - //if batchKey does not work, then the batch size would be 3 - batchMax: 2, - batchHandler, - batchKey, - }), - ]); - - let count = 0; - [1, 2, 3, 4].forEach(() => { - execute(link, { - query, - }).subscribe({ - next: (d) => { - try { - expect(d).toEqual(data); - } catch (e) { - reject(e); - } - }, - error: reject, - complete: () => { - count++; - if (count === 4) { - try { - expect(batchHandler.mock.calls.length).toBe(2); - resolve(); - } catch (e) { - reject(e); - } - } - }, - }); - }); - } - ); + }); }); }); diff --git a/src/link/context/__tests__/index.ts b/src/link/context/__tests__/index.ts index 8aa7a6be03e..c80be213a95 100644 --- a/src/link/context/__tests__/index.ts +++ b/src/link/context/__tests__/index.ts @@ -4,7 +4,8 @@ import { ApolloLink } from "../../core"; import { Observable } from "../../../utilities/observables/Observable"; import { execute } from "../../core/execute"; import { setContext } from "../index"; -import { itAsync } from "../../../testing"; +import { wait } from "../../../testing"; +import { ObservableStream } from "../../../testing/internal"; const sleep = (ms: number) => new Promise((s) => setTimeout(s, ms)); const query = gql` @@ -18,68 +19,53 @@ const data = { foo: { bar: true }, }; -itAsync( - "can be used to set the context with a simple function", - (resolve, reject) => { - const withContext = setContext(() => ({ dynamicallySet: true })); +it("can be used to set the context with a simple function", async () => { + const withContext = setContext(() => ({ dynamicallySet: true })); - const mockLink = new ApolloLink((operation) => { - expect(operation.getContext().dynamicallySet).toBe(true); - return Observable.of({ data }); - }); + const mockLink = new ApolloLink((operation) => { + expect(operation.getContext().dynamicallySet).toBe(true); + return Observable.of({ data }); + }); - const link = withContext.concat(mockLink); + const link = withContext.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe((result) => { - expect(result.data).toEqual(data); - resolve(); - }); - } -); - -itAsync( - "can be used to set the context with a function returning a promise", - (resolve, reject) => { - const withContext = setContext(() => - Promise.resolve({ dynamicallySet: true }) - ); - - const mockLink = new ApolloLink((operation) => { - expect(operation.getContext().dynamicallySet).toBe(true); - return Observable.of({ data }); - }); + await expect(stream).toEmitValue({ data }); +}); - const link = withContext.concat(mockLink); +it("can be used to set the context with a function returning a promise", async () => { + const withContext = setContext(() => + Promise.resolve({ dynamicallySet: true }) + ); - execute(link, { query }).subscribe((result) => { - expect(result.data).toEqual(data); - resolve(); - }); - } -); - -itAsync( - "can be used to set the context with a function returning a promise that is delayed", - (resolve, reject) => { - const withContext = setContext(() => - sleep(25).then(() => ({ dynamicallySet: true })) - ); - - const mockLink = new ApolloLink((operation) => { - expect(operation.getContext().dynamicallySet).toBe(true); - return Observable.of({ data }); - }); + const mockLink = new ApolloLink((operation) => { + expect(operation.getContext().dynamicallySet).toBe(true); + return Observable.of({ data }); + }); - const link = withContext.concat(mockLink); + const link = withContext.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe((result) => { - expect(result.data).toEqual(data); - resolve(); - }); - } -); + await expect(stream).toEmitValue({ data }); +}); + +it("can be used to set the context with a function returning a promise that is delayed", async () => { + const withContext = setContext(() => + sleep(25).then(() => ({ dynamicallySet: true })) + ); + + const mockLink = new ApolloLink((operation) => { + expect(operation.getContext().dynamicallySet).toBe(true); + return Observable.of({ data }); + }); -itAsync("handles errors in the lookup correclty", (resolve, reject) => { + const link = withContext.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); + + await expect(stream).toEmitValue({ data }); +}); + +it("handles errors in the lookup correclty", async () => { const withContext = setContext(() => sleep(5).then(() => { throw new Error("dang"); @@ -92,32 +78,27 @@ itAsync("handles errors in the lookup correclty", (resolve, reject) => { const link = withContext.concat(mockLink); - execute(link, { query }).subscribe(reject, (e) => { - expect(e.message).toBe("dang"); - resolve(); - }); + const stream = new ObservableStream(execute(link, { query })); + + await expect(stream).toEmitError("dang"); }); -itAsync( - "handles errors in the lookup correclty with a normal function", - (resolve, reject) => { - const withContext = setContext(() => { - throw new Error("dang"); - }); - const mockLink = new ApolloLink((operation) => { - return Observable.of({ data }); - }); +it("handles errors in the lookup correctly with a normal function", async () => { + const withContext = setContext(() => { + throw new Error("dang"); + }); - const link = withContext.concat(mockLink); + const mockLink = new ApolloLink((operation) => { + return Observable.of({ data }); + }); - execute(link, { query }).subscribe(reject, (e) => { - expect(e.message).toBe("dang"); - resolve(); - }); - } -); + const link = withContext.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); -itAsync("has access to the request information", (resolve, reject) => { + await expect(stream).toEmitError("dang"); +}); + +it("has access to the request information", async () => { const withContext = setContext(({ operationName, query, variables }) => sleep(1).then(() => Promise.resolve({ @@ -137,13 +118,14 @@ itAsync("has access to the request information", (resolve, reject) => { }); const link = withContext.concat(mockLink); + const stream = new ObservableStream( + execute(link, { query, variables: { id: 1 } }) + ); - execute(link, { query, variables: { id: 1 } }).subscribe((result) => { - expect(result.data).toEqual(data); - resolve(); - }); + await expect(stream).toEmitValue({ data }); }); -itAsync("has access to the context at execution time", (resolve, reject) => { + +it("has access to the context at execution time", async () => { const withContext = setContext((_, { count }) => sleep(1).then(() => ({ count: count + 1 })) ); @@ -155,14 +137,14 @@ itAsync("has access to the context at execution time", (resolve, reject) => { }); const link = withContext.concat(mockLink); + const stream = new ObservableStream( + execute(link, { query, context: { count: 1 } }) + ); - execute(link, { query, context: { count: 1 } }).subscribe((result) => { - expect(result.data).toEqual(data); - resolve(); - }); + await expect(stream).toEmitValue({ data }); }); -itAsync("unsubscribes correctly", (resolve, reject) => { +it("unsubscribes correctly", async () => { const withContext = setContext((_, { count }) => sleep(1).then(() => ({ count: count + 1 })) ); @@ -175,18 +157,19 @@ itAsync("unsubscribes correctly", (resolve, reject) => { const link = withContext.concat(mockLink); - let handle = execute(link, { - query, - context: { count: 1 }, - }).subscribe((result) => { - expect(result.data).toEqual(data); - handle.unsubscribe(); - resolve(); - }); + const stream = new ObservableStream( + execute(link, { + query, + context: { count: 1 }, + }) + ); + + await expect(stream).toEmitValue({ data }); + stream.unsubscribe(); }); -itAsync("unsubscribes without throwing before data", (resolve, reject) => { - let called: boolean; +it("unsubscribes without throwing before data", async () => { + let called!: boolean; const withContext = setContext((_, { count }) => { called = true; return sleep(1).then(() => ({ count: count + 1 })); @@ -209,51 +192,43 @@ itAsync("unsubscribes without throwing before data", (resolve, reject) => { query, context: { count: 1 }, }).subscribe((result) => { - reject("should have unsubscribed"); + throw new Error("should have unsubscribed"); }); - setTimeout(() => { - handle.unsubscribe(); - expect(called).toBe(true); - resolve(); - }, 10); + await wait(10); + + handle.unsubscribe(); + expect(called).toBe(true); }); -itAsync( - "does not start the next link subscription if the upstream subscription is already closed", - (resolve, reject) => { - let promiseResolved = false; - const withContext = setContext(() => - sleep(5).then(() => { - promiseResolved = true; - return { dynamicallySet: true }; - }) - ); - - let mockLinkCalled = false; - const mockLink = new ApolloLink(() => { - mockLinkCalled = true; - reject("link should not be called"); - return new Observable((observer) => { - observer.error("link should not have been observed"); - }); - }); +it("does not start the next link subscription if the upstream subscription is already closed", async () => { + let promiseResolved = false; + const withContext = setContext(() => + sleep(5).then(() => { + promiseResolved = true; + return { dynamicallySet: true }; + }) + ); - const link = withContext.concat(mockLink); + let mockLinkCalled = false; + const mockLink = new ApolloLink(() => { + mockLinkCalled = true; + throw new Error("link should not be called"); + }); - let subscriptionReturnedData = false; - let handle = execute(link, { query }).subscribe((result) => { - subscriptionReturnedData = true; - reject("subscription should not return data"); - }); + const link = withContext.concat(mockLink); - handle.unsubscribe(); + let subscriptionReturnedData = false; + let handle = execute(link, { query }).subscribe((result) => { + subscriptionReturnedData = true; + throw new Error("subscription should not return data"); + }); - setTimeout(() => { - expect(promiseResolved).toBe(true); - expect(mockLinkCalled).toBe(false); - expect(subscriptionReturnedData).toBe(false); - resolve(); - }, 10); - } -); + handle.unsubscribe(); + + await wait(10); + + expect(promiseResolved).toBe(true); + expect(mockLinkCalled).toBe(false); + expect(subscriptionReturnedData).toBe(false); +}); diff --git a/src/link/error/__tests__/index.ts b/src/link/error/__tests__/index.ts index 5e886ff58d3..0a3bf2bbfb8 100644 --- a/src/link/error/__tests__/index.ts +++ b/src/link/error/__tests__/index.ts @@ -5,10 +5,10 @@ import { execute } from "../../core/execute"; import { ServerError, throwServerError } from "../../utils/throwServerError"; import { Observable } from "../../../utilities/observables/Observable"; import { onError, ErrorLink } from "../"; -import { itAsync } from "../../../testing"; +import { ObservableStream } from "../../../testing/internal"; describe("error handling", () => { - itAsync("has an easy way to handle GraphQL errors", (resolve, reject) => { + it("has an easy way to handle GraphQL errors", async () => { const query = gql` { foo { @@ -17,7 +17,7 @@ describe("error handling", () => { } `; - let called: boolean; + let called = false; const errorLink = onError(({ graphQLErrors, networkError }) => { expect(graphQLErrors![0].message).toBe("resolver blew up"); called = true; @@ -34,47 +34,44 @@ describe("error handling", () => { ); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe((result) => { - expect(result.errors![0].message).toBe("resolver blew up"); - expect(called).toBe(true); - resolve(); - }); + const result = await stream.takeNext(); + + expect(result.errors![0].message).toBe("resolver blew up"); + expect(called).toBe(true); }); - itAsync( - "has an easy way to log client side (network) errors", - (resolve, reject) => { - const query = gql` - query Foo { - foo { - bar - } + + it("has an easy way to log client side (network) errors", async () => { + const query = gql` + query Foo { + foo { + bar } - `; + } + `; - let called: boolean; - const errorLink = onError(({ operation, networkError }) => { - expect(networkError!.message).toBe("app is crashing"); - expect(operation.operationName).toBe("Foo"); - called = true; - }); + let called = false; + const errorLink = onError(({ operation, networkError }) => { + expect(networkError!.message).toBe("app is crashing"); + expect(operation.operationName).toBe("Foo"); + called = true; + }); - const mockLink = new ApolloLink((operation) => { - throw new Error("app is crashing"); - }); + const mockLink = new ApolloLink((operation) => { + throw new Error("app is crashing"); + }); - const link = errorLink.concat(mockLink); + const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(e.message).toBe("app is crashing"); - expect(called).toBe(true); - resolve(); - }, - }); - } - ); - itAsync("captures errors within links", (resolve, reject) => { + const error = await stream.takeError(); + + expect(error.message).toBe("app is crashing"); + expect(called).toBe(true); + }); + + it("captures errors within links", async () => { const query = gql` query Foo { foo { @@ -83,7 +80,7 @@ describe("error handling", () => { } `; - let called: boolean; + let called = false; const errorLink = onError(({ operation, networkError }) => { expect(networkError!.message).toBe("app is crashing"); expect(operation.operationName).toBe("Foo"); @@ -97,89 +94,79 @@ describe("error handling", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(e.message).toBe("app is crashing"); - expect(called).toBe(true); - resolve(); - }, - }); + const error = await stream.takeError(); + + expect(error.message).toBe("app is crashing"); + expect(called).toBe(true); }); - itAsync( - "captures networkError.statusCode within links", - (resolve, reject) => { - const query = gql` - query Foo { - foo { - bar - } + + it("captures networkError.statusCode within links", async () => { + const query = gql` + query Foo { + foo { + bar } - `; - - let called: boolean; - const errorLink = onError(({ operation, networkError }) => { - expect(networkError!.message).toBe("app is crashing"); - expect(networkError!.name).toBe("ServerError"); - expect((networkError as ServerError).statusCode).toBe(500); - expect((networkError as ServerError).response.ok).toBe(false); - expect(operation.operationName).toBe("Foo"); - called = true; - }); + } + `; - const mockLink = new ApolloLink((operation) => { - return new Observable((obs) => { - const response = { status: 500, ok: false } as Response; - throwServerError(response, "ServerError", "app is crashing"); - }); + let called = false; + const errorLink = onError(({ operation, networkError }) => { + expect(networkError!.message).toBe("app is crashing"); + expect(networkError!.name).toBe("ServerError"); + expect((networkError as ServerError).statusCode).toBe(500); + expect((networkError as ServerError).response.ok).toBe(false); + expect(operation.operationName).toBe("Foo"); + called = true; + }); + + const mockLink = new ApolloLink((operation) => { + return new Observable((obs) => { + const response = { status: 500, ok: false } as Response; + throwServerError(response, "ServerError", "app is crashing"); }); + }); - const link = errorLink.concat(mockLink); + const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(e.message).toBe("app is crashing"); - expect(called).toBe(true); - resolve(); - }, - }); - } - ); - itAsync( - "sets graphQLErrors to undefined if networkError.result is an empty string", - (resolve, reject) => { - const query = gql` - query Foo { - foo { - bar - } + const error = await stream.takeError(); + + expect(error.message).toBe("app is crashing"); + expect(called).toBe(true); + }); + + it("sets graphQLErrors to undefined if networkError.result is an empty string", async () => { + const query = gql` + query Foo { + foo { + bar } - `; + } + `; - let called: boolean; - const errorLink = onError(({ graphQLErrors }) => { - expect(graphQLErrors).toBeUndefined(); - called = true; - }); + let called = false; + const errorLink = onError(({ graphQLErrors }) => { + expect(graphQLErrors).toBeUndefined(); + called = true; + }); - const mockLink = new ApolloLink((operation) => { - return new Observable((obs) => { - const response = { status: 500, ok: false } as Response; - throwServerError(response, "", "app is crashing"); - }); + const mockLink = new ApolloLink((operation) => { + return new Observable((obs) => { + const response = { status: 500, ok: false } as Response; + throwServerError(response, "", "app is crashing"); }); + }); - const link = errorLink.concat(mockLink); + const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(called).toBe(true); - resolve(); - }, - }); - } - ); - itAsync("completes if no errors", (resolve, reject) => { + await expect(stream).toEmitError(); + expect(called).toBe(true); + }); + + it("completes if no errors", async () => { const query = gql` { foo { @@ -197,12 +184,13 @@ describe("error handling", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - complete: resolve, - }); + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); - itAsync("allows an error to be ignored", (resolve, reject) => { + + it("allows an error to be ignored", async () => { const query = gql` { foo { @@ -225,17 +213,16 @@ describe("error handling", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - next: ({ errors, data }) => { - expect(errors).toBe(null); - expect(data).toEqual({ foo: { id: 1 } }); - }, - complete: resolve, + await expect(stream).toEmitValue({ + errors: null, + data: { foo: { id: 1 } }, }); + await expect(stream).toComplete(); }); - itAsync("can be unsubcribed", (resolve, reject) => { + it("can be unsubcribed", async () => { const query = gql` { foo { @@ -258,62 +245,56 @@ describe("error handling", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - const sub = execute(link, { query }).subscribe({ - complete: () => { - reject("completed"); - }, - }); - - sub.unsubscribe(); + stream.unsubscribe(); - setTimeout(resolve, 10); + await expect(stream).not.toEmitAnything(); }); - itAsync( - "includes the operation and any data along with a graphql error", - (resolve, reject) => { - const query = gql` - query Foo { - foo { - bar - } + it("includes the operation and any data along with a graphql error", async () => { + const query = gql` + query Foo { + foo { + bar } - `; - - let called: boolean; - const errorLink = onError(({ graphQLErrors, response, operation }) => { - expect(graphQLErrors![0].message).toBe("resolver blew up"); - expect(response!.data!.foo).toBe(true); - expect(operation.operationName).toBe("Foo"); - expect(operation.getContext().bar).toBe(true); - called = true; - }); + } + `; - const mockLink = new ApolloLink((operation) => - Observable.of({ - data: { foo: true }, - errors: [ - { - message: "resolver blew up", - }, - ], - } as any) - ); - - const link = errorLink.concat(mockLink); - - execute(link, { query, context: { bar: true } }).subscribe((result) => { - expect(result.errors![0].message).toBe("resolver blew up"); - expect(called).toBe(true); - resolve(); - }); - } - ); + let called = false; + const errorLink = onError(({ graphQLErrors, response, operation }) => { + expect(graphQLErrors![0].message).toBe("resolver blew up"); + expect(response!.data!.foo).toBe(true); + expect(operation.operationName).toBe("Foo"); + expect(operation.getContext().bar).toBe(true); + called = true; + }); + + const mockLink = new ApolloLink((operation) => + Observable.of({ + data: { foo: true }, + errors: [ + { + message: "resolver blew up", + }, + ], + } as any) + ); + + const link = errorLink.concat(mockLink); + const stream = new ObservableStream( + execute(link, { query, context: { bar: true } }) + ); + + const result = await stream.takeNext(); + + expect(result.errors![0].message).toBe("resolver blew up"); + expect(called).toBe(true); + }); }); describe("error handling with class", () => { - itAsync("has an easy way to handle GraphQL errors", (resolve, reject) => { + it("has an easy way to handle GraphQL errors", async () => { const query = gql` { foo { @@ -322,7 +303,7 @@ describe("error handling with class", () => { } `; - let called: boolean; + let called = false; const errorLink = new ErrorLink(({ graphQLErrors, networkError }) => { expect(graphQLErrors![0].message).toBe("resolver blew up"); called = true; @@ -339,46 +320,43 @@ describe("error handling with class", () => { ); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe((result) => { - expect(result!.errors![0].message).toBe("resolver blew up"); - expect(called).toBe(true); - resolve(); - }); + const result = await stream.takeNext(); + + expect(result!.errors![0].message).toBe("resolver blew up"); + expect(called).toBe(true); }); - itAsync( - "has an easy way to log client side (network) errors", - (resolve, reject) => { - const query = gql` - { - foo { - bar - } + + it("has an easy way to log client side (network) errors", async () => { + const query = gql` + { + foo { + bar } - `; + } + `; - let called: boolean; - const errorLink = new ErrorLink(({ networkError }) => { - expect(networkError!.message).toBe("app is crashing"); - called = true; - }); + let called = false; + const errorLink = new ErrorLink(({ networkError }) => { + expect(networkError!.message).toBe("app is crashing"); + called = true; + }); - const mockLink = new ApolloLink((operation) => { - throw new Error("app is crashing"); - }); + const mockLink = new ApolloLink((operation) => { + throw new Error("app is crashing"); + }); - const link = errorLink.concat(mockLink); + const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(e.message).toBe("app is crashing"); - expect(called).toBe(true); - resolve(); - }, - }); - } - ); - itAsync("captures errors within links", (resolve, reject) => { + const error = await stream.takeError(); + + expect(error.message).toBe("app is crashing"); + expect(called).toBe(true); + }); + + it("captures errors within links", async () => { const query = gql` { foo { @@ -387,7 +365,7 @@ describe("error handling with class", () => { } `; - let called: boolean; + let called = false; const errorLink = new ErrorLink(({ networkError }) => { expect(networkError!.message).toBe("app is crashing"); called = true; @@ -400,16 +378,15 @@ describe("error handling with class", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - execute(link, { query }).subscribe({ - error: (e) => { - expect(e.message).toBe("app is crashing"); - expect(called).toBe(true); - resolve(); - }, - }); + const error = await stream.takeError(); + + expect(error.message).toBe("app is crashing"); + expect(called).toBe(true); }); - itAsync("completes if no errors", (resolve, reject) => { + + it("completes if no errors", async () => { const query = gql` { foo { @@ -428,11 +405,13 @@ describe("error handling with class", () => { const link = errorLink.concat(mockLink); - execute(link, { query }).subscribe({ - complete: resolve, - }); + const stream = new ObservableStream(execute(link, { query })); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); - itAsync("can be unsubcribed", (resolve, reject) => { + + it("can be unsubcribed", async () => { const query = gql` { foo { @@ -455,16 +434,11 @@ describe("error handling with class", () => { }); const link = errorLink.concat(mockLink); + const stream = new ObservableStream(execute(link, { query })); - const sub = execute(link, { query }).subscribe({ - complete: () => { - reject("completed"); - }, - }); - - sub.unsubscribe(); + stream.unsubscribe(); - setTimeout(resolve, 10); + await expect(stream).not.toEmitAnything(); }); }); @@ -491,118 +465,92 @@ describe("support for request retrying", () => { message: "some other error", }; - itAsync( - "returns the retried request when forward(operation) is called", - (resolve, reject) => { - let errorHandlerCalled = false; - - let timesCalled = 0; - const mockHttpLink = new ApolloLink((operation) => { - if (timesCalled === 0) { - timesCalled++; - // simulate the first request being an error - return new Observable((observer) => { - observer.next(ERROR_RESPONSE as any); - observer.complete(); - }); - } else { - return new Observable((observer) => { - observer.next(GOOD_RESPONSE); - observer.complete(); - }); - } - }); + it("returns the retried request when forward(operation) is called", async () => { + let errorHandlerCalled = false; - const errorLink = new ErrorLink( - ({ graphQLErrors, response, operation, forward }) => { - try { - if (graphQLErrors) { - errorHandlerCalled = true; - expect(graphQLErrors).toEqual(ERROR_RESPONSE.errors); - expect(response!.data).not.toBeDefined(); - expect(operation.operationName).toBe("Foo"); - expect(operation.getContext().bar).toBe(true); - // retry operation if it resulted in an error - return forward(operation); - } - } catch (error) { - reject(error); - } - } - ); - - const link = errorLink.concat(mockHttpLink); - - execute(link, { query: QUERY, context: { bar: true } }).subscribe({ - next(result) { - try { - expect(errorHandlerCalled).toBe(true); - expect(result).toEqual(GOOD_RESPONSE); - } catch (error) { - return reject(error); - } - }, - complete() { - resolve(); - }, - }); - } - ); - - itAsync( - "supports retrying when the initial request had networkError", - (resolve, reject) => { - let errorHandlerCalled = false; - - let timesCalled = 0; - const mockHttpLink = new ApolloLink((operation) => { - if (timesCalled === 0) { - timesCalled++; - // simulate the first request being an error - return new Observable((observer) => { - observer.error(NETWORK_ERROR); - }); - } else { - return new Observable((observer) => { - observer.next(GOOD_RESPONSE); - observer.complete(); - }); + let timesCalled = 0; + const mockHttpLink = new ApolloLink((operation) => { + if (timesCalled === 0) { + timesCalled++; + // simulate the first request being an error + return new Observable((observer) => { + observer.next(ERROR_RESPONSE as any); + observer.complete(); + }); + } else { + return new Observable((observer) => { + observer.next(GOOD_RESPONSE); + observer.complete(); + }); + } + }); + + const errorLink = new ErrorLink( + ({ graphQLErrors, response, operation, forward }) => { + if (graphQLErrors) { + errorHandlerCalled = true; + expect(graphQLErrors).toEqual(ERROR_RESPONSE.errors); + expect(response!.data).not.toBeDefined(); + expect(operation.operationName).toBe("Foo"); + expect(operation.getContext().bar).toBe(true); + // retry operation if it resulted in an error + return forward(operation); } - }); + } + ); + + const link = errorLink.concat(mockHttpLink); + + const stream = new ObservableStream( + execute(link, { query: QUERY, context: { bar: true } }) + ); + + await expect(stream).toEmitValue(GOOD_RESPONSE); + expect(errorHandlerCalled).toBe(true); + await expect(stream).toComplete(); + }); + + it("supports retrying when the initial request had networkError", async () => { + let errorHandlerCalled = false; - const errorLink = new ErrorLink( - ({ networkError, response, operation, forward }) => { - try { - if (networkError) { - errorHandlerCalled = true; - expect(networkError).toEqual(NETWORK_ERROR); - return forward(operation); - } - } catch (error) { - reject(error); - } + let timesCalled = 0; + const mockHttpLink = new ApolloLink((operation) => { + if (timesCalled === 0) { + timesCalled++; + // simulate the first request being an error + return new Observable((observer) => { + observer.error(NETWORK_ERROR); + }); + } else { + return new Observable((observer) => { + observer.next(GOOD_RESPONSE); + observer.complete(); + }); + } + }); + + const errorLink = new ErrorLink( + ({ networkError, response, operation, forward }) => { + if (networkError) { + errorHandlerCalled = true; + expect(networkError).toEqual(NETWORK_ERROR); + return forward(operation); } - ); - - const link = errorLink.concat(mockHttpLink); - - execute(link, { query: QUERY, context: { bar: true } }).subscribe({ - next(result) { - try { - expect(errorHandlerCalled).toBe(true); - expect(result).toEqual(GOOD_RESPONSE); - } catch (error) { - return reject(error); - } - }, - complete() { - resolve(); - }, - }); - } - ); + } + ); + + const link = errorLink.concat(mockHttpLink); + + const stream = new ObservableStream( + execute(link, { query: QUERY, context: { bar: true } }) + ); + + await expect(stream).toEmitValue(GOOD_RESPONSE); + expect(errorHandlerCalled).toBe(true); + await expect(stream).toComplete(); + }); - itAsync("returns errors from retried requests", (resolve, reject) => { + it("returns errors from retried requests", async () => { let errorHandlerCalled = false; let timesCalled = 0; @@ -623,38 +571,25 @@ describe("support for request retrying", () => { const errorLink = new ErrorLink( ({ graphQLErrors, networkError, response, operation, forward }) => { - try { - if (graphQLErrors) { - errorHandlerCalled = true; - expect(graphQLErrors).toEqual(ERROR_RESPONSE.errors); - expect(response!.data).not.toBeDefined(); - expect(operation.operationName).toBe("Foo"); - expect(operation.getContext().bar).toBe(true); - // retry operation if it resulted in an error - return forward(operation); - } - } catch (error) { - reject(error); + if (graphQLErrors) { + errorHandlerCalled = true; + expect(graphQLErrors).toEqual(ERROR_RESPONSE.errors); + expect(response!.data).not.toBeDefined(); + expect(operation.operationName).toBe("Foo"); + expect(operation.getContext().bar).toBe(true); + // retry operation if it resulted in an error + return forward(operation); } } ); const link = errorLink.concat(mockHttpLink); - let observerNextCalled = false; - execute(link, { query: QUERY, context: { bar: true } }).subscribe({ - next(result) { - // should not be called - observerNextCalled = true; - }, - error(error) { - // note that complete will not be after an error - // therefore we should end the test here with resolve() - expect(errorHandlerCalled).toBe(true); - expect(observerNextCalled).toBe(false); - expect(error).toEqual(NETWORK_ERROR); - resolve(); - }, - }); + const stream = new ObservableStream( + execute(link, { query: QUERY, context: { bar: true } }) + ); + + await expect(stream).toEmitError(NETWORK_ERROR); + expect(errorHandlerCalled).toBe(true); }); }); diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index ad58e4c40c9..5d8e9a155dd 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -19,7 +19,8 @@ import { ClientParseError } from "../serializeFetchParameter"; import { ServerParseError } from "../parseAndCheckHttpResponse"; import { FetchResult, ServerError } from "../../.."; import { voidFetchDuringEachTest } from "./helpers"; -import { itAsync } from "../../../testing"; +import { wait } from "../../../testing"; +import { ObservableStream } from "../../../testing/internal"; const sampleQuery = gql` query SampleQuery { @@ -85,22 +86,6 @@ const sampleSubscriptionWithDefer = gql` } `; -function makeCallback( - resolve: () => void, - reject: (error: Error) => void, - callback: (...args: TArgs) => any -) { - return function () { - try { - // @ts-expect-error - callback.apply(this, arguments); - resolve(); - } catch (error) { - reject(error as Error); - } - } as typeof callback; -} - function convertBatchedBody(body: BodyInit | null | undefined) { return JSON.parse(body as string); } @@ -153,26 +138,18 @@ describe("HttpLink", () => { expect(() => new HttpLink()).not.toThrow(); }); - itAsync( - "constructor creates link that can call next and then complete", - (resolve, reject) => { - const next = jest.fn(); - const link = new HttpLink({ uri: "/data" }); - const observable = execute(link, { - query: sampleQuery, - }); - observable.subscribe({ - next, - error: (error) => expect(false), - complete: () => { - expect(next).toHaveBeenCalledTimes(1); - resolve(); - }, - }); - } - ); + it("constructor creates link that can call next and then complete", async () => { + const link = new HttpLink({ uri: "/data" }); + const observable = execute(link, { + query: sampleQuery, + }); + const stream = new ObservableStream(observable); - itAsync("supports using a GET request", (resolve, reject) => { + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + }); + + it("supports using a GET request", async () => { const variables = { params: "stub" }; const extensions = { myExtension: "foo" }; @@ -183,298 +160,290 @@ describe("HttpLink", () => { includeUnusedVariables: true, }); - execute(link, { query: sampleQuery, variables, extensions }).subscribe({ - next: makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(body).toBeUndefined(); - expect(method).toBe("GET"); - expect(uri).toBe( - "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D&extensions=%7B%22myExtension%22%3A%22foo%22%7D" - ); - }), - error: (error) => reject(error), + const observable = execute(link, { + query: sampleQuery, + variables, + extensions, }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + + expect(body).toBeUndefined(); + expect(method).toBe("GET"); + expect(uri).toBe( + "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D&extensions=%7B%22myExtension%22%3A%22foo%22%7D" + ); }); - itAsync("supports using a GET request with search", (resolve, reject) => { + it("supports using a GET request with search", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data?foo=bar", fetchOptions: { method: "GET" }, }); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); - execute(link, { query: sampleQuery, variables }).subscribe({ - next: makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(body).toBeUndefined(); - expect(method).toBe("GET"); - expect(uri).toBe( - "/data?foo=bar&query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" - ); - }), - error: (error) => reject(error), - }); + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + + expect(body).toBeUndefined(); + expect(method).toBe("GET"); + expect(uri).toBe( + "/data?foo=bar&query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" + ); }); - itAsync( - "supports using a GET request on the context", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - }); + it("supports using a GET request on the context", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + }); - execute(link, { - query: sampleQuery, - variables, - context: { - fetchOptions: { method: "GET" }, - }, - }).subscribe( - makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(body).toBeUndefined(); - expect(method).toBe("GET"); - expect(uri).toBe( - "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" - ); - }) - ); - } - ); + const observable = execute(link, { + query: sampleQuery, + variables, + context: { + fetchOptions: { method: "GET" }, + }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + + expect(body).toBeUndefined(); + expect(method).toBe("GET"); + expect(uri).toBe( + "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" + ); + }); - itAsync("uses GET with useGETForQueries", (resolve, reject) => { + it("uses GET with useGETForQueries", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "/data", useGETForQueries: true, }); - execute(link, { + const observable = execute(link, { query: sampleQuery, variables, - }).subscribe( - makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(body).toBeUndefined(); - expect(method).toBe("GET"); - expect(uri).toBe( - "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" - ); - }) + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + expect(body).toBeUndefined(); + expect(method).toBe("GET"); + expect(uri).toBe( + "/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D&operationName=SampleQuery&variables=%7B%7D" ); }); - itAsync( - "uses POST for mutations with useGETForQueries", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - useGETForQueries: true, - }); + it("uses POST for mutations with useGETForQueries", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + useGETForQueries: true, + }); - execute(link, { - query: sampleMutation, - variables, - }).subscribe( - makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(body).toBeDefined(); - expect(method).toBe("POST"); - expect(uri).toBe("/data"); - }) - ); - } - ); - - itAsync( - "strips unused variables, respecting nested fragments", - (resolve, reject) => { - const link = createHttpLink({ uri: "/data" }); - - const query = gql` - query PEOPLE($declaredAndUsed: String, $declaredButUnused: Int) { - people(surprise: $undeclared, noSurprise: $declaredAndUsed) { - ... on Doctor { - specialty(var: $usedByInlineFragment) - } - ...LawyerFragment + const observable = execute(link, { + query: sampleMutation, + variables, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + + expect(body).toBeDefined(); + expect(method).toBe("POST"); + expect(uri).toBe("/data"); + }); + + it("strips unused variables, respecting nested fragments", async () => { + const link = createHttpLink({ uri: "/data" }); + + const query = gql` + query PEOPLE($declaredAndUsed: String, $declaredButUnused: Int) { + people(surprise: $undeclared, noSurprise: $declaredAndUsed) { + ... on Doctor { + specialty(var: $usedByInlineFragment) } + ...LawyerFragment } - fragment LawyerFragment on Lawyer { - caseCount(var: $usedByNamedFragment) - } - `; + } + fragment LawyerFragment on Lawyer { + caseCount(var: $usedByNamedFragment) + } + `; + + const variables = { + unused: "strip", + declaredButUnused: "strip", + declaredAndUsed: "keep", + undeclared: "keep", + usedByInlineFragment: "keep", + usedByNamedFragment: "keep", + }; + + const observable = execute(link, { + query, + variables, + }); + const stream = new ObservableStream(observable); - const variables = { - unused: "strip", - declaredButUnused: "strip", + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + const [uri, options] = fetchMock.lastCall()!; + const { method, body } = options!; + + expect(JSON.parse(body as string)).toEqual({ + operationName: "PEOPLE", + query: print(query), + variables: { declaredAndUsed: "keep", undeclared: "keep", usedByInlineFragment: "keep", usedByNamedFragment: "keep", - }; - - execute(link, { - query, - variables, - }).subscribe({ - next: makeCallback(resolve, reject, () => { - const [uri, options] = fetchMock.lastCall()!; - const { method, body } = options!; - expect(JSON.parse(body as string)).toEqual({ - operationName: "PEOPLE", - query: print(query), - variables: { - declaredAndUsed: "keep", - undeclared: "keep", - usedByInlineFragment: "keep", - usedByNamedFragment: "keep", - }, - }); - expect(method).toBe("POST"); - expect(uri).toBe("/data"); - }), - error: (error) => reject(error), - }); - } - ); + }, + }); + expect(method).toBe("POST"); + expect(uri).toBe("/data"); + }); - itAsync( - "should add client awareness settings to request headers", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - }); + it("should add client awareness settings to request headers", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + }); - const clientAwareness = { - name: "Some Client Name", - version: "1.0.1", - }; + const clientAwareness = { + name: "Some Client Name", + version: "1.0.1", + }; - execute(link, { - query: sampleQuery, - variables, - context: { - clientAwareness, - }, - }).subscribe( - makeCallback(resolve, reject, () => { - const [, options] = fetchMock.lastCall()!; - const { headers } = options as any; - expect(headers["apollographql-client-name"]).toBeDefined(); - expect(headers["apollographql-client-name"]).toEqual( - clientAwareness.name - ); - expect(headers["apollographql-client-version"]).toBeDefined(); - expect(headers["apollographql-client-version"]).toEqual( - clientAwareness.version - ); - }) - ); - } - ); + const observable = execute(link, { + query: sampleQuery, + variables, + context: { + clientAwareness, + }, + }); + const stream = new ObservableStream(observable); - itAsync( - "should not add empty client awareness settings to request headers", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "/data", - }); + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); - const hasOwn = Object.prototype.hasOwnProperty; - const clientAwareness = {}; - execute(link, { - query: sampleQuery, - variables, - context: { - clientAwareness, - }, - }).subscribe( - makeCallback(resolve, reject, () => { - const [, options] = fetchMock.lastCall()!; - const { headers } = options as any; - expect(hasOwn.call(headers, "apollographql-client-name")).toBe( - false - ); - expect(hasOwn.call(headers, "apollographql-client-version")).toBe( - false - ); - }) - ); - } - ); + const [, options] = fetchMock.lastCall()!; + const { headers } = options as any; + expect(headers["apollographql-client-name"]).toBeDefined(); + expect(headers["apollographql-client-name"]).toEqual( + clientAwareness.name + ); + expect(headers["apollographql-client-version"]).toBeDefined(); + expect(headers["apollographql-client-version"]).toEqual( + clientAwareness.version + ); + }); - itAsync( - "throws for GET if the variables can't be stringified", - (resolve, reject) => { - const link = createHttpLink({ - uri: "/data", - useGETForQueries: true, - includeUnusedVariables: true, - }); + it("should not add empty client awareness settings to request headers", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "/data", + }); - let b; - const a: any = { b }; - b = { a }; - a.b = b; - const variables = { - a, - b, - }; - execute(link, { query: sampleQuery, variables }).subscribe( - (result) => { - reject("next should have been thrown from the link"); - }, - makeCallback(resolve, reject, (e: ClientParseError) => { - expect(e.message).toMatch(/Variables map is not serializable/); - expect(e.parseError.message).toMatch( - /Converting circular structure to JSON/ - ); - }) - ); - } - ); + const hasOwn = Object.prototype.hasOwnProperty; + const clientAwareness = {}; + const observable = execute(link, { + query: sampleQuery, + variables, + context: { + clientAwareness, + }, + }); + const stream = new ObservableStream(observable); - itAsync( - "throws for GET if the extensions can't be stringified", - (resolve, reject) => { - const link = createHttpLink({ - uri: "/data", - useGETForQueries: true, - includeExtensions: true, - }); + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); - let b; - const a: any = { b }; - b = { a }; - a.b = b; - const extensions = { - a, - b, - }; - execute(link, { query: sampleQuery, extensions }).subscribe( - (result) => { - reject("next should have been thrown from the link"); - }, - makeCallback(resolve, reject, (e: ClientParseError) => { - expect(e.message).toMatch(/Extensions map is not serializable/); - expect(e.parseError.message).toMatch( - /Converting circular structure to JSON/ - ); - }) - ); - } - ); + const [, options] = fetchMock.lastCall()!; + const { headers } = options as any; + expect(hasOwn.call(headers, "apollographql-client-name")).toBe(false); + expect(hasOwn.call(headers, "apollographql-client-version")).toBe(false); + }); + + it("throws for GET if the variables can't be stringified", async () => { + const link = createHttpLink({ + uri: "/data", + useGETForQueries: true, + includeUnusedVariables: true, + }); + + let b; + const a: any = { b }; + b = { a }; + a.b = b; + const variables = { + a, + b, + }; + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/Variables map is not serializable/); + expect(error.parseError.message).toMatch( + /Converting circular structure to JSON/ + ); + }); + + it("throws for GET if the extensions can't be stringified", async () => { + const link = createHttpLink({ + uri: "/data", + useGETForQueries: true, + includeExtensions: true, + }); + + let b; + const a: any = { b }; + b = { a }; + a.b = b; + const extensions = { + a, + b, + }; + const observable = execute(link, { query: sampleQuery, extensions }); + const stream = new ObservableStream(observable); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/Extensions map is not serializable/); + expect(error.parseError.message).toMatch( + /Converting circular structure to JSON/ + ); + }); it("raises warning if called with concat", () => { const link = createHttpLink(); @@ -494,71 +463,65 @@ describe("HttpLink", () => { expect(() => createHttpLink()).not.toThrow(); }); - itAsync("calls next and then complete", (resolve, reject) => { - const next = jest.fn(); + it("calls next and then complete", async () => { const link = createHttpLink({ uri: "data" }); const observable = execute(link, { query: sampleQuery, }); - observable.subscribe({ - next, - error: (error) => reject(error), - complete: makeCallback(resolve, reject, () => { - expect(next).toHaveBeenCalledTimes(1); - }), - }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); - itAsync("calls error when fetch fails", (resolve, reject) => { + it("calls error when fetch fails", async () => { const link = createHttpLink({ uri: "error" }); const observable = execute(link, { query: sampleQuery, }); - observable.subscribe( - (result) => reject("next should not have been called"), - makeCallback(resolve, reject, (error: TypeError) => { - expect(error).toEqual(mockError.throws); - }), - () => reject("complete should not have been called") - ); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitError(mockError.throws); }); - itAsync("calls error when fetch fails", (resolve, reject) => { + it("calls error when fetch fails", async () => { const link = createHttpLink({ uri: "error" }); const observable = execute(link, { query: sampleMutation, }); - observable.subscribe( - (result) => reject("next should not have been called"), - makeCallback(resolve, reject, (error: TypeError) => { - expect(error).toEqual(mockError.throws); - }), - () => reject("complete should not have been called") - ); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitError(mockError.throws); }); - itAsync("unsubscribes without calling subscriber", (resolve, reject) => { + it("unsubscribes without calling subscriber", async () => { const link = createHttpLink({ uri: "data" }); const observable = execute(link, { query: sampleQuery, }); const subscription = observable.subscribe( - (result) => reject("next should not have been called"), - (error) => reject(error), - () => reject("complete should not have been called") + () => { + throw new Error("next should not have been called"); + }, + (error) => { + throw error; + }, + () => { + throw "complete should not have been called"; + } ); subscription.unsubscribe(); + expect(subscription.closed).toBe(true); - setTimeout(resolve, 50); + + // Ensure none of the callbacks throw after our assertion + await wait(10); }); - const verifyRequest = ( + const verifyRequest = async ( link: ApolloLink, - resolve: () => void, - includeExtensions: boolean, - reject: (error: any) => any + includeExtensions: boolean ) => { - const next = jest.fn(); const context = { info: "stub" }; const variables = { params: "stub" }; @@ -567,57 +530,37 @@ describe("HttpLink", () => { context, variables, }); - observable.subscribe({ - next, - error: (error) => reject(error), - complete: () => { - try { - let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); - expect(body.query).toBe(print(sampleMutation)); - expect(body.variables).toEqual({}); - expect(body.context).not.toBeDefined(); - if (includeExtensions) { - expect(body.extensions).toBeDefined(); - } else { - expect(body.extensions).not.toBeDefined(); - } - expect(next).toHaveBeenCalledTimes(1); - - resolve(); - } catch (e) { - reject(e); - } - }, - }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); + + let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); + expect(body.query).toBe(print(sampleMutation)); + expect(body.variables).toEqual({}); + expect(body.context).not.toBeDefined(); + if (includeExtensions) { + expect(body.extensions).toBeDefined(); + } else { + expect(body.extensions).not.toBeDefined(); + } }; - itAsync( - "passes all arguments to multiple fetch body including extensions", - (resolve, reject) => { - const link = createHttpLink({ uri: "data", includeExtensions: true }); - verifyRequest( - link, - () => verifyRequest(link, resolve, true, reject), - true, - reject - ); - } - ); + it("passes all arguments to multiple fetch body including extensions", async () => { + const link = createHttpLink({ uri: "data", includeExtensions: true }); - itAsync( - "passes all arguments to multiple fetch body excluding extensions", - (resolve, reject) => { - const link = createHttpLink({ uri: "data" }); - verifyRequest( - link, - () => verifyRequest(link, resolve, false, reject), - false, - reject - ); - } - ); + await verifyRequest(link, true); + await verifyRequest(link, true); + }); + + it("passes all arguments to multiple fetch body excluding extensions", async () => { + const link = createHttpLink({ uri: "data" }); + + await verifyRequest(link, false); + await verifyRequest(link, false); + }); - itAsync("calls multiple subscribers", (resolve, reject) => { + it("calls multiple subscribers", async () => { const link = createHttpLink({ uri: "data" }); const context = { info: "stub" }; const variables = { params: "stub" }; @@ -630,159 +573,144 @@ describe("HttpLink", () => { observable.subscribe(subscriber); observable.subscribe(subscriber); - setTimeout(() => { - expect(subscriber.next).toHaveBeenCalledTimes(2); - expect(subscriber.complete).toHaveBeenCalledTimes(2); - expect(subscriber.error).not.toHaveBeenCalled(); - expect(fetchMock.calls().length).toBe(2); - resolve(); - }, 50); - }); - - itAsync( - "calls remaining subscribers after unsubscribe", - (resolve, reject) => { - const link = createHttpLink({ uri: "data" }); - const context = { info: "stub" }; - const variables = { params: "stub" }; - - const observable = execute(link, { - query: sampleMutation, - context, - variables, - }); + await wait(50); - observable.subscribe(subscriber); + expect(subscriber.next).toHaveBeenCalledTimes(2); + expect(subscriber.complete).toHaveBeenCalledTimes(2); + expect(subscriber.error).not.toHaveBeenCalled(); + expect(fetchMock.calls().length).toBe(2); + }); - setTimeout(() => { - const subscription = observable.subscribe(subscriber); - subscription.unsubscribe(); - }, 10); + it("calls remaining subscribers after unsubscribe", async () => { + const link = createHttpLink({ uri: "data" }); + const context = { info: "stub" }; + const variables = { params: "stub" }; - setTimeout( - makeCallback(resolve, reject, () => { - expect(subscriber.next).toHaveBeenCalledTimes(1); - expect(subscriber.complete).toHaveBeenCalledTimes(1); - expect(subscriber.error).not.toHaveBeenCalled(); - resolve(); - }), - 50 - ); - } - ); + const observable = execute(link, { + query: sampleMutation, + context, + variables, + }); + + observable.subscribe(subscriber); + + await wait(10); + + const subscription = observable.subscribe(subscriber); + subscription.unsubscribe(); + + await wait(50); - itAsync("allows for dynamic endpoint setting", (resolve, reject) => { + expect(subscriber.next).toHaveBeenCalledTimes(1); + expect(subscriber.complete).toHaveBeenCalledTimes(1); + expect(subscriber.error).not.toHaveBeenCalled(); + }); + + it("allows for dynamic endpoint setting", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "data" }); - execute(link, { + const observable = execute(link, { query: sampleQuery, variables, context: { uri: "data2" }, - }).subscribe((result) => { - expect(result).toEqual(data2); - resolve(); }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue(data2); }); - itAsync( - "adds headers to the request from the context", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - headers: { authorization: "1234" }, - }); - return forward(operation).map((result) => { - const { headers } = operation.getContext(); - try { - expect(headers).toBeDefined(); - } catch (e) { - reject(e); - } - return result; - }); + it("adds headers to the request from the context", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + headers: { authorization: "1234" }, }); - const link = middleware.concat(createHttpLink({ uri: "data" })); - - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const headers = fetchMock.lastCall()![1]!.headers as any; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + return forward(operation).map((result) => { + const { headers } = operation.getContext(); + expect(headers).toBeDefined(); - itAsync("adds headers to the request from the setup", (resolve, reject) => { + return result; + }); + }); + const link = middleware.concat(createHttpLink({ uri: "data" })); + + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const headers = fetchMock.lastCall()![1]!.headers as any; + + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("adds headers to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "data", headers: { authorization: "1234" }, }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const headers = fetchMock.lastCall()![1]!.headers as any; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const headers = fetchMock.lastCall()![1]!.headers as any; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); }); - itAsync( - "prioritizes context headers over setup headers", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - headers: { authorization: "1234" }, - }); - return forward(operation); + it("prioritizes context headers over setup headers", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + headers: { authorization: "1234" }, }); - const link = middleware.concat( - createHttpLink({ uri: "data", headers: { authorization: "no user" } }) - ); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: "data", headers: { authorization: "no user" } }) + ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const headers = fetchMock.lastCall()![1]!.headers as any; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); - itAsync( - "adds headers to the request from the context on an operation", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ uri: "data" }); + await expect(stream).toEmitNext(); - const context = { - headers: { authorization: "1234" }, - }; - execute(link, { - query: sampleQuery, - variables, - context, - }).subscribe( - makeCallback(resolve, reject, () => { - const headers = fetchMock.lastCall()![1]!.headers as any; - expect(headers.authorization).toBe("1234"); - expect(headers["content-type"]).toBe("application/json"); - expect(headers.accept).toBe("*/*"); - }) - ); - } - ); + const headers = fetchMock.lastCall()![1]!.headers as any; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); - itAsync("adds creds to the request from the context", (resolve, reject) => { + it("adds headers to the request from the context on an operation", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ uri: "data" }); + + const context = { + headers: { authorization: "1234" }, + }; + const observable = execute(link, { + query: sampleQuery, + variables, + context, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const headers = fetchMock.lastCall()![1]!.headers as any; + expect(headers.authorization).toBe("1234"); + expect(headers["content-type"]).toBe("application/json"); + expect(headers.accept).toBe("*/*"); + }); + + it("adds creds to the request from the context", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -792,50 +720,50 @@ describe("HttpLink", () => { }); const link = middleware.concat(createHttpLink({ uri: "data" })); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); }); - itAsync("adds creds to the request from the setup", (resolve, reject) => { + it("adds creds to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "data", credentials: "same-team-yo" }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); }); - itAsync( - "prioritizes creds from the context over the setup", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - credentials: "same-team-yo", - }); - return forward(operation); + it("prioritizes creds from the context over the setup", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + credentials: "same-team-yo", }); - const link = middleware.concat( - createHttpLink({ uri: "data", credentials: "error" }) - ); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: "data", credentials: "error" }) + ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const creds = fetchMock.lastCall()![1]!.credentials; - expect(creds).toBe("same-team-yo"); - }) - ); - } - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); - itAsync("adds uri to the request from the context", (resolve, reject) => { + const creds = fetchMock.lastCall()![1]!.credentials; + expect(creds).toBe("same-team-yo"); + }); + + it("adds uri to the request from the context", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -845,27 +773,29 @@ describe("HttpLink", () => { }); const link = middleware.concat(createHttpLink()); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const uri = fetchMock.lastUrl(); - expect(uri).toBe("/data"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/data"); }); - itAsync("adds uri to the request from the setup", (resolve, reject) => { + it("adds uri to the request from the setup", async () => { const variables = { params: "stub" }; const link = createHttpLink({ uri: "data" }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const uri = fetchMock.lastUrl(); - expect(uri).toBe("/data"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/data"); }); - itAsync("prioritizes context uri over setup uri", (resolve, reject) => { + it("prioritizes context uri over setup uri", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -877,168 +807,139 @@ describe("HttpLink", () => { createHttpLink({ uri: "data", credentials: "error" }) ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const uri = fetchMock.lastUrl(); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); - expect(uri).toBe("/apollo"); - }) - ); + await expect(stream).toEmitNext(); + + const uri = fetchMock.lastUrl(); + expect(uri).toBe("/apollo"); }); - itAsync("allows uri to be a function", (resolve, reject) => { + it("allows uri to be a function", async () => { const variables = { params: "stub" }; const customFetch: typeof fetch = (uri, options) => { const { operationName } = convertBatchedBody(options!.body); - try { - expect(operationName).toBe("SampleQuery"); - } catch (e) { - reject(e); - } + expect(operationName).toBe("SampleQuery"); + return fetch("dataFunc", options); }; const link = createHttpLink({ fetch: customFetch }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - expect(fetchMock.lastUrl()).toBe("/dataFunc"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + expect(fetchMock.lastUrl()).toBe("/dataFunc"); }); - itAsync( - "adds fetchOptions to the request from the setup", - (resolve, reject) => { - const variables = { params: "stub" }; - const link = createHttpLink({ - uri: "data", - fetchOptions: { someOption: "foo", mode: "no-cors" }, - }); + it("adds fetchOptions to the request from the setup", async () => { + const variables = { params: "stub" }; + const link = createHttpLink({ + uri: "data", + fetchOptions: { someOption: "foo", mode: "no-cors" }, + }); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const { someOption, mode, headers } = - fetchMock.lastCall()![1] as any; - expect(someOption).toBe("foo"); - expect(mode).toBe("no-cors"); - expect(headers["content-type"]).toBe("application/json"); - }) - ); - } - ); - - itAsync( - "adds fetchOptions to the request from the context", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - fetchOptions: { - someOption: "foo", - }, - }); - return forward(operation); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const { someOption, mode, headers } = fetchMock.lastCall()![1] as any; + expect(someOption).toBe("foo"); + expect(mode).toBe("no-cors"); + expect(headers["content-type"]).toBe("application/json"); + }); + + it("adds fetchOptions to the request from the context", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + fetchOptions: { + someOption: "foo", + }, }); - const link = middleware.concat(createHttpLink({ uri: "data" })); + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: "data" })); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const { someOption } = fetchMock.lastCall()![1] as any; - expect(someOption).toBe("foo"); - resolve(); - }) - ); - } - ); - - itAsync( - "uses the latest window.fetch function if options.fetch not configured", - (resolve, reject) => { - const httpLink = createHttpLink({ uri: "data" }); - - const fetch = window.fetch; - expect(typeof fetch).toBe("function"); - - const fetchSpy = jest.spyOn(window, "fetch"); - fetchSpy.mockImplementation(() => - Promise.resolve({ - text() { - return Promise.resolve( - JSON.stringify({ - data: { hello: "from spy" }, - }) - ); - }, - } as Response) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); - const spyFn = window.fetch; - expect(spyFn).not.toBe(fetch); + await expect(stream).toEmitNext(); - subscriptions.add( - execute(httpLink, { - query: sampleQuery, - }).subscribe({ - error: reject, + const { someOption } = fetchMock.lastCall()![1] as any; + expect(someOption).toBe("foo"); + }); - next(result) { - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - data: { hello: "from spy" }, - }); - - fetchSpy.mockRestore(); - expect(window.fetch).toBe(fetch); - - subscriptions.add( - execute(httpLink, { - query: sampleQuery, - }).subscribe({ - error: reject, - next(result) { - expect(result).toEqual({ - data: { hello: "world" }, - }); - resolve(); - }, - }) - ); - }, - }) - ); - } - ); - - itAsync( - "uses the print option function when defined", - (resolve, reject) => { - const customPrinter = jest.fn( - (ast: ASTNode, originalPrint: typeof print) => { - return stripIgnoredCharacters(originalPrint(ast)); - } - ); + it("uses the latest window.fetch function if options.fetch not configured", async () => { + const httpLink = createHttpLink({ uri: "data" }); - const httpLink = createHttpLink({ uri: "data", print: customPrinter }); + const fetch = window.fetch; + expect(typeof fetch).toBe("function"); - execute(httpLink, { - query: sampleQuery, - context: { - fetchOptions: { method: "GET" }, - }, - }).subscribe( - makeCallback(resolve, reject, () => { - expect(customPrinter).toHaveBeenCalledTimes(1); - const [uri] = fetchMock.lastCall()!; - expect(uri).toBe( - "/data?query=query%20SampleQuery%7Bstub%7Bid%7D%7D&operationName=SampleQuery&variables=%7B%7D" + const fetchSpy = jest.spyOn(window, "fetch"); + fetchSpy.mockImplementation(() => + Promise.resolve({ + text() { + return Promise.resolve( + JSON.stringify({ + data: { hello: "from spy" }, + }) ); - }) - ); - } - ); + }, + } as Response) + ); + + const spyFn = window.fetch; + expect(spyFn).not.toBe(fetch); + + const stream = new ObservableStream( + execute(httpLink, { query: sampleQuery }) + ); + + await expect(stream).toEmitValue({ data: { hello: "from spy" } }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); + expect(window.fetch).toBe(fetch); + + const stream2 = new ObservableStream( + execute(httpLink, { query: sampleQuery }) + ); + + await expect(stream2).toEmitValue({ data: { hello: "world" } }); + }); + + it("uses the print option function when defined", async () => { + const customPrinter = jest.fn( + (ast: ASTNode, originalPrint: typeof print) => { + return stripIgnoredCharacters(originalPrint(ast)); + } + ); + + const httpLink = createHttpLink({ uri: "data", print: customPrinter }); + + const observable = execute(httpLink, { + query: sampleQuery, + context: { + fetchOptions: { method: "GET" }, + }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); - itAsync("prioritizes context over setup", (resolve, reject) => { + expect(customPrinter).toHaveBeenCalledTimes(1); + const [uri] = fetchMock.lastCall()!; + expect(uri).toBe( + "/data?query=query%20SampleQuery%7Bstub%7Bid%7D%7D&operationName=SampleQuery&variables=%7B%7D" + ); + }); + + it("prioritizes context over setup", async () => { const variables = { params: "stub" }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ @@ -1052,55 +953,53 @@ describe("HttpLink", () => { createHttpLink({ uri: "data", fetchOptions: { someOption: "bar" } }) ); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - const { someOption } = fetchMock.lastCall()![1] as any; - expect(someOption).toBe("foo"); - }) - ); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + + const { someOption } = fetchMock.lastCall()![1] as any; + expect(someOption).toBe("foo"); }); - itAsync( - "allows for not sending the query with the request", - (resolve, reject) => { - const variables = { params: "stub" }; - const middleware = new ApolloLink((operation, forward) => { - operation.setContext({ - http: { - includeQuery: false, - includeExtensions: true, - }, - }); - operation.extensions.persistedQuery = { hash: "1234" }; - return forward(operation); + it("allows for not sending the query with the request", async () => { + const variables = { params: "stub" }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + http: { + includeQuery: false, + includeExtensions: true, + }, }); - const link = middleware.concat(createHttpLink({ uri: "data" })); + operation.extensions.persistedQuery = { hash: "1234" }; + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: "data" })); - execute(link, { query: sampleQuery, variables }).subscribe( - makeCallback(resolve, reject, () => { - let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); - expect(body.query).not.toBeDefined(); - expect(body.extensions).toEqual({ - persistedQuery: { hash: "1234" }, - }); - resolve(); - }) - ); - } - ); + await expect(stream).toEmitNext(); + + let body = convertBatchedBody(fetchMock.lastCall()![1]!.body); - itAsync("sets the raw response on context", (resolve, reject) => { + expect(body.query).not.toBeDefined(); + expect(body.extensions).toEqual({ + persistedQuery: { hash: "1234" }, + }); + }); + + it("sets the raw response on context", async () => { const middleware = new ApolloLink((operation, forward) => { return new Observable((ob) => { const op = forward(operation); const sub = op.subscribe({ next: ob.next.bind(ob), error: ob.error.bind(ob), - complete: makeCallback(resolve, reject, () => { + complete: () => { expect(operation.getContext().response.headers.toBeDefined); ob.complete(); - }), + }, }); return () => { @@ -1111,12 +1010,11 @@ describe("HttpLink", () => { const link = middleware.concat(createHttpLink({ uri: "data", fetch })); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - resolve(); - }, - () => {} - ); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitNext(); + await expect(stream).toComplete(); }); it("removes @client fields from the query before sending it to the server", async () => { @@ -1195,26 +1093,22 @@ describe("HttpLink", () => { describe("Dev warnings", () => { voidFetchDuringEachTest(); - itAsync("warns if fetch is undeclared", (resolve, reject) => { + it("warns if fetch is undeclared", async () => { try { createHttpLink({ uri: "data" }); - reject("warning wasn't called"); + throw new Error("warning wasn't called"); } catch (e) { - makeCallback(resolve, reject, () => - expect((e as Error).message).toMatch(/has not been found globally/) - )(); + expect((e as Error).message).toMatch(/has not been found globally/); } }); - itAsync("warns if fetch is undefined", (resolve, reject) => { + it("warns if fetch is undefined", async () => { window.fetch = undefined as any; try { createHttpLink({ uri: "data" }); - reject("warning wasn't called"); + throw new Error("warning wasn't called"); } catch (e) { - makeCallback(resolve, reject, () => - expect((e as Error).message).toMatch(/has not been found globally/) - )(); + expect((e as Error).message).toMatch(/has not been found globally/); } }); @@ -1259,18 +1153,18 @@ describe("HttpLink", () => { beforeEach(() => { fetch.mockReset(); }); - itAsync("makes it easy to do stuff on a 401", (resolve, reject) => { + it("makes it easy to do stuff on a 401", async () => { const middleware = new ApolloLink((operation, forward) => { return new Observable((ob) => { fetch.mockReturnValueOnce(Promise.resolve({ status: 401, text })); const op = forward(operation); const sub = op.subscribe({ next: ob.next.bind(ob), - error: makeCallback(resolve, reject, (e: ServerError) => { + error: (e: ServerError) => { expect(e.message).toMatch(/Received status code 401/); expect(e.statusCode).toEqual(401); ob.error(e); - }), + }, complete: ob.complete.bind(ob), }); @@ -1284,115 +1178,94 @@ describe("HttpLink", () => { createHttpLink({ uri: "data", fetch: fetch as any }) ); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - () => {} - ); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitError(); }); - itAsync("throws an error if response code is > 300", (resolve, reject) => { + it("throws an error if response code is > 300", async () => { fetch.mockReturnValueOnce(Promise.resolve({ status: 400, text })); const link = createHttpLink({ uri: "data", fetch: fetch as any }); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - makeCallback(resolve, reject, (e: ServerError) => { - expect(e.message).toMatch(/Received status code 400/); - expect(e.statusCode).toBe(400); - expect(e.result).toEqual(responseBody); - }) + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + const error: ServerError = await stream.takeError(); + + expect(error.message).toMatch(/Received status code 400/); + expect(error.statusCode).toBe(400); + expect(error.result).toEqual(responseBody); + }); + + it("throws an error if response code is > 300 and handles string response body", async () => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 302, text: textWithStringError }) ); + const link = createHttpLink({ uri: "data", fetch: fetch as any }); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + const error: ServerError = await stream.takeError(); + + expect(error.message).toMatch(/Received status code 302/); + expect(error.statusCode).toBe(302); + expect(error.result).toEqual(responseBody); }); - itAsync( - "throws an error if response code is > 300 and handles string response body", - (resolve, reject) => { - fetch.mockReturnValueOnce( - Promise.resolve({ status: 302, text: textWithStringError }) - ); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - makeCallback(resolve, reject, (e: ServerError) => { - expect(e.message).toMatch(/Received status code 302/); - expect(e.statusCode).toBe(302); - expect(e.result).toEqual(responseBody); - }) - ); - } - ); - itAsync( - "throws an error if response code is > 300 and returns data", - (resolve, reject) => { - fetch.mockReturnValueOnce( - Promise.resolve({ status: 400, text: textWithData }) - ); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); + it("throws an error if response code is > 300 and returns data", async () => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: textWithData }) + ); - let called = false; + const link = createHttpLink({ uri: "data", fetch: fetch as any }); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - called = true; - expect(result).toEqual(responseBody); - }, - (e) => { - expect(called).toBe(true); - expect(e.message).toMatch(/Received status code 400/); - expect(e.statusCode).toBe(400); - expect(e.result).toEqual(responseBody); - resolve(); - } - ); - } - ); - itAsync( - "throws an error if only errors are returned", - (resolve, reject) => { - fetch.mockReturnValueOnce( - Promise.resolve({ status: 400, text: textWithErrors }) - ); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("should not have called result because we have no data"); - }, - (e) => { - expect(e.message).toMatch(/Received status code 400/); - expect(e.statusCode).toBe(400); - expect(e.result).toEqual(responseBody); - resolve(); - } - ); - } - ); - itAsync( - "throws an error if empty response from the server ", - (resolve, reject) => { - fetch.mockReturnValueOnce(Promise.resolve({ text })); - text.mockReturnValueOnce(Promise.resolve('{ "body": "boo" }')); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); + const result = await stream.takeNext(); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - makeCallback(resolve, reject, (e: Error) => { - expect(e.message).toMatch( - /Server response was missing for query 'SampleQuery'/ - ); - }) - ); - } - ); - itAsync("throws if the body can't be stringified", (resolve, reject) => { + expect(result).toEqual(responseBody); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/Received status code 400/); + expect(error.statusCode).toBe(400); + expect(error.result).toEqual(responseBody); + }); + + it("throws an error if only errors are returned", async () => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: textWithErrors }) + ); + + const link = createHttpLink({ uri: "data", fetch: fetch as any }); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/Received status code 400/); + expect(error.statusCode).toBe(400); + expect(error.result).toEqual(responseBody); + }); + + it("throws an error if empty response from the server ", async () => { + fetch.mockReturnValueOnce(Promise.resolve({ text })); + text.mockReturnValueOnce(Promise.resolve('{ "body": "boo" }')); + const link = createHttpLink({ uri: "data", fetch: fetch as any }); + + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + const error = await stream.takeError(); + + expect(error.message).toMatch( + /Server response was missing for query 'SampleQuery'/ + ); + }); + + it("throws if the body can't be stringified", async () => { fetch.mockReturnValueOnce(Promise.resolve({ data: {}, text })); const link = createHttpLink({ uri: "data", @@ -1408,16 +1281,14 @@ describe("HttpLink", () => { a, b, }; - execute(link, { query: sampleQuery, variables }).subscribe( - (result) => { - reject("next should have been thrown from the link"); - }, - makeCallback(resolve, reject, (e: ClientParseError) => { - expect(e.message).toMatch(/Payload is not serializable/); - expect(e.parseError.message).toMatch( - /Converting circular structure to JSON/ - ); - }) + const observable = execute(link, { query: sampleQuery, variables }); + const stream = new ObservableStream(observable); + + const error: ClientParseError = await stream.takeError(); + + expect(error.message).toMatch(/Payload is not serializable/); + expect(error.parseError.message).toMatch( + /Converting circular structure to JSON/ ); }); @@ -1563,51 +1434,41 @@ describe("HttpLink", () => { const body = "{"; const unparsableJson = jest.fn(() => Promise.resolve(body)); - itAsync( - "throws a Server error if response is > 300 with unparsable json", - (resolve, reject) => { - fetch.mockReturnValueOnce( - Promise.resolve({ status: 400, text: unparsableJson }) - ); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); + it("throws a Server error if response is > 300 with unparsable json", async () => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: unparsableJson }) + ); + const link = createHttpLink({ uri: "data", fetch: fetch as any }); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - makeCallback(resolve, reject, (e: ServerParseError) => { - expect(e.message).toMatch( - "Response not successful: Received status code 400" - ); - expect(e.statusCode).toBe(400); - expect(e.response).toBeDefined(); - expect(e.bodyText).toBe(undefined); - }) - ); - } - ); + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); - itAsync( - "throws a ServerParse error if response is 200 with unparsable json", - (resolve, reject) => { - fetch.mockReturnValueOnce( - Promise.resolve({ status: 200, text: unparsableJson }) - ); - const link = createHttpLink({ uri: "data", fetch: fetch as any }); + const error: ServerParseError = await stream.takeError(); - execute(link, { query: sampleQuery }).subscribe( - (result) => { - reject("next should have been thrown from the network"); - }, - makeCallback(resolve, reject, (e: ServerParseError) => { - expect(e.message).toMatch(/JSON/); - expect(e.statusCode).toBe(200); - expect(e.response).toBeDefined(); - expect(e.bodyText).toBe(body); - }) - ); - } - ); + expect(error.message).toMatch( + "Response not successful: Received status code 400" + ); + expect(error.statusCode).toBe(400); + expect(error.response).toBeDefined(); + expect(error.bodyText).toBe(undefined); + }); + + it("throws a ServerParse error if response is 200 with unparsable json", async () => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 200, text: unparsableJson }) + ); + const link = createHttpLink({ uri: "data", fetch: fetch as any }); + + const observable = execute(link, { query: sampleQuery }); + const stream = new ObservableStream(observable); + + const error: ServerParseError = await stream.takeError(); + + expect(error.message).toMatch(/JSON/); + expect(error.statusCode).toBe(200); + expect(error.response).toBeDefined(); + expect(error.bodyText).toBe(body); + }); }); describe("Multipart responses", () => { @@ -1854,72 +1715,63 @@ describe("HttpLink", () => { ); }); - itAsync( - "sets correct accept header on request with deferred query", - (resolve, reject) => { - const stream = Readable.from( - body.split("\r\n").map((line) => line + "\r\n") - ); - const fetch = jest.fn(async () => ({ - status: 200, - body: stream, - headers: new Headers({ "Content-Type": "multipart/mixed" }), - })); - const link = new HttpLink({ - fetch: fetch as any, - }); - execute(link, { - query: sampleDeferredQuery, - }).subscribe( - makeCallback(resolve, reject, () => { - expect(fetch).toHaveBeenCalledWith( - "/graphql", - expect.objectContaining({ - headers: { - "content-type": "application/json", - accept: - "multipart/mixed;deferSpec=20220824,application/json", - }, - }) - ); - }) - ); - } - ); + it("sets correct accept header on request with deferred query", async () => { + const stream = Readable.from( + body.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ "Content-Type": "multipart/mixed" }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + const observable = execute(link, { query: sampleDeferredQuery }); + const observableStream = new ObservableStream(observable); + + await expect(observableStream).toEmitNext(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: "multipart/mixed;deferSpec=20220824,application/json", + }, + }) + ); + }); // ensure that custom directives beginning with '@defer..' do not trigger // custom accept header for multipart responses - itAsync( - "sets does not set accept header on query with custom directive begging with @defer", - (resolve, reject) => { - const stream = Readable.from( - body.split("\r\n").map((line) => line + "\r\n") - ); - const fetch = jest.fn(async () => ({ - status: 200, - body: stream, - headers: new Headers({ "Content-Type": "multipart/mixed" }), - })); - const link = new HttpLink({ - fetch: fetch as any, - }); - execute(link, { - query: sampleQueryCustomDirective, - }).subscribe( - makeCallback(resolve, reject, () => { - expect(fetch).toHaveBeenCalledWith( - "/graphql", - expect.objectContaining({ - headers: { - accept: "*/*", - "content-type": "application/json", - }, - }) - ); - }) - ); - } - ); + it("sets does not set accept header on query with custom directive begging with @defer", async () => { + const stream = Readable.from( + body.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ "Content-Type": "multipart/mixed" }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + const observable = execute(link, { query: sampleQueryCustomDirective }); + const observableStream = new ObservableStream(observable); + + await expect(observableStream).toEmitNext(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + accept: "*/*", + "content-type": "application/json", + }, + }) + ); + }); }); describe("subscriptions", () => { @@ -2194,38 +2046,34 @@ describe("HttpLink", () => { ); }); - itAsync( - "sets correct accept header on request with subscription", - (resolve, reject) => { - const stream = Readable.from( - subscriptionsBody.split("\r\n").map((line) => line + "\r\n") - ); - const fetch = jest.fn(async () => ({ - status: 200, - body: stream, - headers: new Headers({ "Content-Type": "multipart/mixed" }), - })); - const link = new HttpLink({ - fetch: fetch as any, - }); - execute(link, { - query: sampleSubscription, - }).subscribe( - makeCallback(resolve, reject, () => { - expect(fetch).toHaveBeenCalledWith( - "/graphql", - expect.objectContaining({ - headers: { - "content-type": "application/json", - accept: - "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", - }, - }) - ); - }) - ); - } - ); + it("sets correct accept header on request with subscription", async () => { + const stream = Readable.from( + subscriptionsBody.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ "Content-Type": "multipart/mixed" }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + const observable = execute(link, { query: sampleSubscription }); + const observableStream = new ObservableStream(observable); + + await expect(observableStream).toEmitNext(); + + expect(fetch).toHaveBeenCalledWith( + "/graphql", + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: + "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", + }, + }) + ); + }); }); }); }); diff --git a/src/link/persisted-queries/__tests__/persisted-queries.test.ts b/src/link/persisted-queries/__tests__/persisted-queries.test.ts index 7b4fecaf99f..84ce1840b81 100644 --- a/src/link/persisted-queries/__tests__/persisted-queries.test.ts +++ b/src/link/persisted-queries/__tests__/persisted-queries.test.ts @@ -9,8 +9,9 @@ import { Observable } from "../../../utilities"; import { createHttpLink } from "../../http/createHttpLink"; import { createPersistedQueryLink as createPersistedQuery, VERSION } from ".."; -import { itAsync } from "../../../testing"; +import { wait } from "../../../testing"; import { toPromise } from "../../utils"; +import { ObservableStream } from "../../../testing/internal"; // Necessary configuration in order to mock multiple requests // to a single (/graphql) endpoint @@ -79,52 +80,53 @@ describe("happy path", () => { fetchMock.restore(); }); - itAsync( - "sends a sha256 hash of the query under extensions", - (resolve, reject) => { - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })) - ); - const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [uri, request] = fetchMock.lastCall()!; - expect(uri).toEqual("/graphql"); - expect(request!.body!).toBe( - JSON.stringify({ - operationName: "Test", - variables, - extensions: { - persistedQuery: { - version: VERSION, - sha256Hash: hash, - }, - }, - }) - ); - resolve(); - }, reject); - } - ); + it("sends a sha256 hash of the query under extensions", async () => { + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })) + ); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [uri, request] = fetchMock.lastCall()!; - itAsync("sends a version along with the request", (resolve, reject) => { + expect(uri).toEqual("/graphql"); + expect(request!.body!).toBe( + JSON.stringify({ + operationName: "Test", + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }) + ); + }); + + it("sends a version along with the request", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: response })) ); const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [uri, request] = fetchMock.lastCall()!; - expect(uri).toEqual("/graphql"); - const parsed = JSON.parse(request!.body!.toString()); - expect(parsed.extensions.persistedQuery.version).toBe(VERSION); - resolve(); - }, reject); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [uri, request] = fetchMock.lastCall()!; + expect(uri).toEqual("/graphql"); + + const parsed = JSON.parse(request!.body!.toString()); + expect(parsed.extensions.persistedQuery.version).toBe(VERSION); }); - itAsync("memoizes between requests", (resolve, reject) => { + it("memoizes between requests", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: response })), @@ -140,15 +142,23 @@ describe("happy path", () => { createHttpLink() ); - execute(link, { query, variables }).subscribe((result) => { + { + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + await expect(stream).toComplete(); + expect(hashSpy).toHaveBeenCalledTimes(1); + } + + { + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + await expect(stream).toComplete(); expect(hashSpy).toHaveBeenCalledTimes(1); - expect(result.data).toEqual(data); - execute(link, { query, variables }).subscribe((result2) => { - expect(hashSpy).toHaveBeenCalledTimes(1); - expect(result2.data).toEqual(data); - resolve(); - }, reject); - }, reject); + } }); it("clears the cache when calling `resetHashCache`", async () => { @@ -177,7 +187,7 @@ describe("happy path", () => { await expect(hashRefs[0]).toBeGarbageCollected(); }); - itAsync("supports loading the hash from other method", (resolve, reject) => { + it("supports loading the hash from other method", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: response })) @@ -187,33 +197,34 @@ describe("happy path", () => { createHttpLink() ); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [uri, request] = fetchMock.lastCall()!; - expect(uri).toEqual("/graphql"); - const parsed = JSON.parse(request!.body!.toString()); - expect(parsed.extensions.persistedQuery.sha256Hash).toBe("foo"); - resolve(); - }, reject); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [uri, request] = fetchMock.lastCall()!; + expect(uri).toEqual("/graphql"); + + const parsed = JSON.parse(request!.body!.toString()); + expect(parsed.extensions.persistedQuery.sha256Hash).toBe("foo"); }); - itAsync("errors if unable to convert to sha256", (resolve, reject) => { + it("errors if unable to convert to sha256", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: response })) ); const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); - execute(link, { query: "1234", variables } as any).subscribe( - reject as any, - (error) => { - expect(error.message).toMatch(/Invalid AST Node/); - resolve(); - } - ); + const observable = execute(link, { query: "1234", variables } as any); + const stream = new ObservableStream(observable); + + const error = await stream.takeError(); + + expect(error.message).toMatch(/Invalid AST Node/); }); - itAsync("unsubscribes correctly", (resolve, reject) => { + it("unsubscribes correctly", async () => { const delay = new ApolloLink(() => { return new Observable((ob) => { setTimeout(() => { @@ -224,92 +235,70 @@ describe("happy path", () => { }); const link = createPersistedQuery({ sha256 }).concat(delay); - const sub = execute(link, { query, variables }).subscribe( - reject, - reject, - reject - ); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); - setTimeout(() => { - sub.unsubscribe(); - resolve(); - }, 10); + await wait(10); + + stream.unsubscribe(); + + await expect(stream).not.toEmitAnything({ timeout: 150 }); }); - itAsync( - "should error if `sha256` and `generateHash` options are both missing", - (resolve, reject) => { - const createPersistedQueryFn = createPersistedQuery as any; - try { - createPersistedQueryFn(); - reject("should have thrown an error"); - } catch (error) { - expect( - (error as Error).message.indexOf( - 'Missing/invalid "sha256" or "generateHash" function' - ) - ).toBe(0); - resolve(); - } - } - ); + it("should error if `sha256` and `generateHash` options are both missing", async () => { + const createPersistedQueryFn = createPersistedQuery as any; + + expect(() => createPersistedQueryFn()).toThrow( + 'Missing/invalid "sha256" or "generateHash" function' + ); + }); - itAsync( - "should error if `sha256` or `generateHash` options are not functions", - (resolve, reject) => { + it.each(["sha256", "generateHash"])( + "should error if `%s` option is not a function", + async (option) => { const createPersistedQueryFn = createPersistedQuery as any; - [{ sha256: "ooops" }, { generateHash: "ooops" }].forEach((options) => { - try { - createPersistedQueryFn(options); - reject("should have thrown an error"); - } catch (error) { - expect( - (error as Error).message.indexOf( - 'Missing/invalid "sha256" or "generateHash" function' - ) - ).toBe(0); - resolve(); - } - }); + + expect(() => createPersistedQueryFn({ [option]: "ooops" })).toThrow( + 'Missing/invalid "sha256" or "generateHash" function' + ); } ); - itAsync( - "should work with a synchronous SHA-256 function", - (resolve, reject) => { - const crypto = require("crypto"); - const sha256Hash = crypto.createHmac("sha256", queryString).digest("hex"); + it("should work with a synchronous SHA-256 function", async () => { + const crypto = require("crypto"); + const sha256Hash = crypto.createHmac("sha256", queryString).digest("hex"); - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })) - ); - const link = createPersistedQuery({ - sha256(data) { - return crypto.createHmac("sha256", data).digest("hex"); + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })) + ); + const link = createPersistedQuery({ + sha256(data) { + return crypto.createHmac("sha256", data).digest("hex"); + }, + }).concat(createHttpLink()); + + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [uri, request] = fetchMock.lastCall()!; + + expect(uri).toEqual("/graphql"); + expect(request!.body!).toBe( + JSON.stringify({ + operationName: "Test", + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: sha256Hash, + }, }, - }).concat(createHttpLink()); - - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [uri, request] = fetchMock.lastCall()!; - expect(uri).toEqual("/graphql"); - expect(request!.body!).toBe( - JSON.stringify({ - operationName: "Test", - variables, - extensions: { - persistedQuery: { - version: VERSION, - sha256Hash: sha256Hash, - }, - }, - }) - ); - resolve(); - }, reject); - } - ); + }) + ); + }); }); describe("failure path", () => { @@ -356,98 +345,99 @@ describe("failure path", () => { }) ); - itAsync( - "sends GET for the first response only with useGETForHashedQueries", - (resolve, reject) => { - const params = new URLSearchParams({ - operationName: "Test", - variables: JSON.stringify({ - id: 1, - }), - extensions: JSON.stringify({ - persistedQuery: { - version: 1, - sha256Hash: hash, - }, - }), - }).toString(); - fetchMock.get( - `/graphql?${params}`, - () => new Promise((resolve) => resolve({ body: errorResponse })) - ); - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })) - ); - const link = createPersistedQuery({ - sha256, - useGETForHashedQueries: true, - }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, failure]] = fetchMock.calls(); - expect(failure!.method).toBe("GET"); - expect(failure!.body).not.toBeDefined(); - const [, [, success]] = fetchMock.calls(); - expect(success!.method).toBe("POST"); - expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); - expect( - JSON.parse(success!.body!.toString()).extensions.persistedQuery - .sha256Hash - ).toBe(hash); - resolve(); - }, reject); - } - ); + it("sends GET for the first response only with useGETForHashedQueries", async () => { + const params = new URLSearchParams({ + operationName: "Test", + variables: JSON.stringify({ + id: 1, + }), + extensions: JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: hash, + }, + }), + }).toString(); + fetchMock.get( + `/graphql?${params}`, + () => new Promise((resolve) => resolve({ body: errorResponse })) + ); + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })) + ); + const link = createPersistedQuery({ + sha256, + useGETForHashedQueries: true, + }).concat(createHttpLink()); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); - itAsync( - "sends POST for both requests without useGETForHashedQueries", - (resolve, reject) => { - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: errorResponse })), - { repeat: 1 } - ); - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })), - { repeat: 1 } - ); - const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, failure]] = fetchMock.calls(); - expect(failure!.method).toBe("POST"); - expect(JSON.parse(failure!.body!.toString())).toEqual({ - operationName: "Test", - variables, - extensions: { - persistedQuery: { - version: VERSION, - sha256Hash: hash, - }, - }, - }); - const [, [, success]] = fetchMock.calls(); - expect(success!.method).toBe("POST"); - expect(JSON.parse(success!.body!.toString())).toEqual({ - operationName: "Test", - query: queryString, - variables, - extensions: { - persistedQuery: { - version: VERSION, - sha256Hash: hash, - }, - }, - }); - resolve(); - }, reject); - } - ); + await expect(stream).toEmitValue({ data }); + + const [[, failure]] = fetchMock.calls(); + + expect(failure!.method).toBe("GET"); + expect(failure!.body).not.toBeDefined(); + + const [, [, success]] = fetchMock.calls(); + + expect(success!.method).toBe("POST"); + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery.sha256Hash + ).toBe(hash); + }); + + it("sends POST for both requests without useGETForHashedQueries", async () => { + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: errorResponse })), + { repeat: 1 } + ); + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })), + { repeat: 1 } + ); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [[, failure]] = fetchMock.calls(); + + expect(failure!.method).toBe("POST"); + expect(JSON.parse(failure!.body!.toString())).toEqual({ + operationName: "Test", + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }); + + const [, [, success]] = fetchMock.calls(); + + expect(success!.method).toBe("POST"); + expect(JSON.parse(success!.body!.toString())).toEqual({ + operationName: "Test", + query: queryString, + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }); + }); // https://github.com/apollographql/apollo-client/pull/7456 - itAsync("forces POST request when sending full query", (resolve, reject) => { + it("forces POST request when sending full query", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: giveUpResponse })), @@ -469,29 +459,33 @@ describe("failure path", () => { return true; }, }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, failure]] = fetchMock.calls(); - expect(failure!.method).toBe("POST"); - expect(JSON.parse(failure!.body!.toString())).toEqual({ - operationName: "Test", - variables, - extensions: { - persistedQuery: { - version: VERSION, - sha256Hash: hash, - }, + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [[, failure]] = fetchMock.calls(); + + expect(failure!.method).toBe("POST"); + expect(JSON.parse(failure!.body!.toString())).toEqual({ + operationName: "Test", + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, }, - }); - const [, [, success]] = fetchMock.calls(); - expect(success!.method).toBe("POST"); - expect(JSON.parse(success!.body!.toString())).toEqual({ - operationName: "Test", - query: queryString, - variables, - }); - resolve(); - }, reject); + }, + }); + + const [, [, success]] = fetchMock.calls(); + + expect(success!.method).toBe("POST"); + expect(JSON.parse(success!.body!.toString())).toEqual({ + operationName: "Test", + query: queryString, + variables, + }); }); it.each([ @@ -583,7 +577,7 @@ describe("failure path", () => { } ); - itAsync("works with multiple errors", (resolve, reject) => { + it("works with multiple errors", async () => { fetchMock.post( "/graphql", () => new Promise((resolve) => resolve({ body: multiResponse })), @@ -595,76 +589,84 @@ describe("failure path", () => { { repeat: 1 } ); const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, failure]] = fetchMock.calls(); - expect(JSON.parse(failure!.body!.toString()).query).not.toBeDefined(); - const [, [, success]] = fetchMock.calls(); - expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); - expect( - JSON.parse(success!.body!.toString()).extensions.persistedQuery - .sha256Hash - ).toBe(hash); - resolve(); - }, reject); + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [[, failure]] = fetchMock.calls(); + + expect(JSON.parse(failure!.body!.toString()).query).not.toBeDefined(); + + const [, [, success]] = fetchMock.calls(); + + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery.sha256Hash + ).toBe(hash); }); describe.each([[400], [500]])("status %s", (status) => { - itAsync( - `handles a ${status} network with a "PERSISTED_QUERY_NOT_FOUND" error and still retries`, - (resolve, reject) => { - let requestCount = 0; - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })), - { repeat: 1 } - ); + it(`handles a ${status} network with a "PERSISTED_QUERY_NOT_FOUND" error and still retries`, async () => { + let requestCount = 0; + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })), + { repeat: 1 } + ); - // mock it again so we can verify it doesn't try anymore - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })), - { repeat: 5 } - ); + // mock it again so we can verify it doesn't try anymore + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })), + { repeat: 5 } + ); - const fetcher = (...args: any[]) => { - if (++requestCount % 2) { - return Promise.resolve({ - json: () => Promise.resolve(errorResponseWithCode), - text: () => Promise.resolve(errorResponseWithCode), - status, - }); - } - // @ts-expect-error - return global.fetch.apply(null, args); - }; - const link = createPersistedQuery({ sha256 }).concat( - createHttpLink({ fetch: fetcher } as any) - ); + const fetcher = (...args: any[]) => { + if (++requestCount % 2) { + return Promise.resolve({ + json: () => Promise.resolve(errorResponseWithCode), + text: () => Promise.resolve(errorResponseWithCode), + status, + }); + } + // @ts-expect-error + return global.fetch.apply(null, args); + }; + const link = createPersistedQuery({ sha256 }).concat( + createHttpLink({ fetch: fetcher } as any) + ); - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, success]] = fetchMock.calls(); - expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); - expect( - JSON.parse(success!.body!.toString()).extensions.persistedQuery - .sha256Hash - ).toBe(hash); - execute(link, { query, variables }).subscribe((secondResult) => { - expect(secondResult.data).toEqual(data); - const [, [, success]] = fetchMock.calls(); - expect(JSON.parse(success!.body!.toString()).query).toBe( - queryString - ); - expect( - JSON.parse(success!.body!.toString()).extensions.persistedQuery - .sha256Hash - ).toBe(hash); - resolve(); - }, reject); - }, reject); + { + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [[, success]] = fetchMock.calls(); + + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery + .sha256Hash + ).toBe(hash); } - ); + + { + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [, [, success]] = fetchMock.calls(); + + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery + .sha256Hash + ).toBe(hash); + } + }); it(`will fail on an unrelated ${status} network error, but still send a hash the next request`, async () => { let failed = false; @@ -711,43 +713,42 @@ describe("failure path", () => { ).toBe(hash); }); - itAsync( - `handles ${status} response network error and graphql error without disabling persistedQuery support`, - (resolve, reject) => { - let failed = false; - fetchMock.post( - "/graphql", - () => new Promise((resolve) => resolve({ body: response })), - { repeat: 1 } - ); + it(`handles ${status} response network error and graphql error without disabling persistedQuery support`, async () => { + let failed = false; + fetchMock.post( + "/graphql", + () => new Promise((resolve) => resolve({ body: response })), + { repeat: 1 } + ); - const fetcher = (...args: any[]) => { - if (!failed) { - failed = true; - return Promise.resolve({ - json: () => Promise.resolve(errorResponse), - text: () => Promise.resolve(errorResponse), - status, - }); - } - // @ts-expect-error - return global.fetch.apply(null, args); - }; - - const link = createPersistedQuery({ sha256 }).concat( - createHttpLink({ fetch: fetcher } as any) - ); + const fetcher = (...args: any[]) => { + if (!failed) { + failed = true; + return Promise.resolve({ + json: () => Promise.resolve(errorResponse), + text: () => Promise.resolve(errorResponse), + status, + }); + } + // @ts-expect-error + return global.fetch.apply(null, args); + }; - execute(link, { query, variables }).subscribe((result) => { - expect(result.data).toEqual(data); - const [[, success]] = fetchMock.calls(); - expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); - expect( - JSON.parse(success!.body!.toString()).extensions - ).not.toBeUndefined(); - resolve(); - }, reject); - } - ); + const link = createPersistedQuery({ sha256 }).concat( + createHttpLink({ fetch: fetcher } as any) + ); + + const observable = execute(link, { query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue({ data }); + + const [[, success]] = fetchMock.calls(); + + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions + ).not.toBeUndefined(); + }); }); }); diff --git a/src/link/ws/__tests__/webSocketLink.ts b/src/link/ws/__tests__/webSocketLink.ts index d24b118c44d..5859d6ed785 100644 --- a/src/link/ws/__tests__/webSocketLink.ts +++ b/src/link/ws/__tests__/webSocketLink.ts @@ -5,7 +5,7 @@ import gql from "graphql-tag"; import { Observable } from "../../../utilities"; import { execute } from "../../core"; import { WebSocketLink } from ".."; -import { itAsync } from "../../../testing"; +import { ObservableStream } from "../../../testing/internal"; const query = gql` query SampleQuery { @@ -43,95 +43,84 @@ describe("WebSocketLink", () => { // it('should pass the correct initialization parameters to the Subscription Client', () => { // }); - itAsync( - "should call request on the client for a query", - (resolve, reject) => { - const result = { data: { data: "result" } }; - const client: any = {}; - const observable = Observable.of(result); - client.__proto__ = SubscriptionClient.prototype; - client.request = jest.fn(); - client.request.mockReturnValueOnce(observable); - const link = new WebSocketLink(client); - - const obs = execute(link, { query }); - expect(obs).toEqual(observable); - obs.subscribe((data) => { - expect(data).toEqual(result); - expect(client.request).toHaveBeenCalledTimes(1); - resolve(); - }); - } - ); - - itAsync( - "should call query on the client for a mutation", - (resolve, reject) => { - const result = { data: { data: "result" } }; - const client: any = {}; - const observable = Observable.of(result); - client.__proto__ = SubscriptionClient.prototype; - client.request = jest.fn(); - client.request.mockReturnValueOnce(observable); - const link = new WebSocketLink(client); - - const obs = execute(link, { query: mutation }); - expect(obs).toEqual(observable); - obs.subscribe((data) => { - expect(data).toEqual(result); - expect(client.request).toHaveBeenCalledTimes(1); - resolve(); - }); - } - ); - - itAsync( - "should call request on the subscriptions client for subscription", - (resolve, reject) => { - const result = { data: { data: "result" } }; - const client: any = {}; - const observable = Observable.of(result); - client.__proto__ = SubscriptionClient.prototype; - client.request = jest.fn(); - client.request.mockReturnValueOnce(observable); - const link = new WebSocketLink(client); - - const obs = execute(link, { query: subscription }); - expect(obs).toEqual(observable); - obs.subscribe((data) => { - expect(data).toEqual(result); - expect(client.request).toHaveBeenCalledTimes(1); - resolve(); - }); - } - ); - - itAsync( - "should call next with multiple results for subscription", - (resolve, reject) => { - const results = [ - { data: { data: "result1" } }, - { data: { data: "result2" } }, - ]; - const client: any = {}; - client.__proto__ = SubscriptionClient.prototype; - client.request = jest.fn(() => { - const copy = [...results]; - return new Observable((observer) => { - observer.next(copy[0]); - observer.next(copy[1]); - }); - }); + it("should call request on the client for a query", async () => { + const result = { data: { data: "result" } }; + const client: any = {}; + const observable = Observable.of(result); + client.__proto__ = SubscriptionClient.prototype; + client.request = jest.fn(); + client.request.mockReturnValueOnce(observable); + const link = new WebSocketLink(client); + + const obs = execute(link, { query }); + expect(obs).toEqual(observable); + + const stream = new ObservableStream(obs); + + await expect(stream).toEmitValue(result); + expect(client.request).toHaveBeenCalledTimes(1); + }); + + it("should call query on the client for a mutation", async () => { + const result = { data: { data: "result" } }; + const client: any = {}; + const observable = Observable.of(result); + client.__proto__ = SubscriptionClient.prototype; + client.request = jest.fn(); + client.request.mockReturnValueOnce(observable); + const link = new WebSocketLink(client); + + const obs = execute(link, { query: mutation }); + expect(obs).toEqual(observable); - const link = new WebSocketLink(client); + const stream = new ObservableStream(obs); - execute(link, { query: subscription }).subscribe((data) => { - expect(client.request).toHaveBeenCalledTimes(1); - expect(data).toEqual(results.shift()); - if (results.length === 0) { - resolve(); - } + await expect(stream).toEmitValue(result); + expect(client.request).toHaveBeenCalledTimes(1); + }); + + it("should call request on the subscriptions client for subscription", async () => { + const result = { data: { data: "result" } }; + const client: any = {}; + const observable = Observable.of(result); + client.__proto__ = SubscriptionClient.prototype; + client.request = jest.fn(); + client.request.mockReturnValueOnce(observable); + const link = new WebSocketLink(client); + + const obs = execute(link, { query: subscription }); + expect(obs).toEqual(observable); + + const stream = new ObservableStream(obs); + + await expect(stream).toEmitValue(result); + expect(client.request).toHaveBeenCalledTimes(1); + }); + + it("should call next with multiple results for subscription", async () => { + const results = [ + { data: { data: "result1" } }, + { data: { data: "result2" } }, + ]; + const client: any = {}; + client.__proto__ = SubscriptionClient.prototype; + client.request = jest.fn(() => { + const copy = [...results]; + return new Observable((observer) => { + observer.next(copy[0]); + observer.next(copy[1]); }); - } - ); + }); + + const link = new WebSocketLink(client); + + const observable = execute(link, { query: subscription }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue(results.shift()); + await expect(stream).toEmitValue(results.shift()); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(0); + }); }); diff --git a/src/react/context/__tests__/ApolloConsumer.test.tsx b/src/react/context/__tests__/ApolloConsumer.test.tsx index aed5384c415..a27a02d782f 100644 --- a/src/react/context/__tests__/ApolloConsumer.test.tsx +++ b/src/react/context/__tests__/ApolloConsumer.test.tsx @@ -7,7 +7,6 @@ import { InMemoryCache as Cache } from "../../../cache"; import { ApolloProvider } from "../ApolloProvider"; import { ApolloConsumer } from "../ApolloConsumer"; import { getApolloContext } from "../ApolloContext"; -import { itAsync } from "../../../testing"; const client = new ApolloClient({ cache: new Cache(), @@ -15,17 +14,13 @@ const client = new ApolloClient({ }); describe(" component", () => { - itAsync("has a render prop", (resolve, reject) => { + it("has a render prop", (done) => { render( {(clientRender) => { - try { - expect(clientRender).toBe(client); - resolve(); - } catch (e) { - reject(e); - } + expect(clientRender).toBe(client); + done(); return null; }} diff --git a/src/react/hoc/__tests__/ssr/getDataFromTree.test.tsx b/src/react/hoc/__tests__/ssr/getDataFromTree.test.tsx index 70daf897951..dc250d8f56d 100644 --- a/src/react/hoc/__tests__/ssr/getDataFromTree.test.tsx +++ b/src/react/hoc/__tests__/ssr/getDataFromTree.test.tsx @@ -8,7 +8,7 @@ import { DocumentNode } from "graphql"; import { ApolloClient, TypedDocumentNode } from "../../../../core"; import { ApolloProvider } from "../../../context"; import { InMemoryCache as Cache } from "../../../../cache"; -import { itAsync, mockSingleLink } from "../../../../testing"; +import { mockSingleLink } from "../../../../testing"; import { Query } from "../../../components"; import { getDataFromTree, getMarkupFromTree } from "../../../ssr"; import { graphql } from "../../graphql"; @@ -543,86 +543,78 @@ describe("SSR", () => { }); }); - itAsync( - "should allow for setting state in a component", - (resolve, reject) => { - const query = gql` - query user($id: ID) { - currentUser(id: $id) { - firstName - } + it("should allow for setting state in a component", async () => { + const query = gql` + query user($id: ID) { + currentUser(id: $id) { + firstName } - `; - const resultData = { currentUser: { firstName: "James" } }; - const variables = { id: "1" }; - const link = mockSingleLink({ - request: { query, variables }, - result: { data: resultData }, - }); - - const cache = new Cache({ addTypename: false }); - const apolloClient = new ApolloClient({ - link, - cache, - }); - - interface Props { - id: string; } - interface Data { - currentUser: { - firstName: string; + `; + const resultData = { currentUser: { firstName: "James" } }; + const variables = { id: "1" }; + const link = mockSingleLink({ + request: { query, variables }, + result: { data: resultData }, + }); + + const cache = new Cache({ addTypename: false }); + const apolloClient = new ApolloClient({ + link, + cache, + }); + + interface Props { + id: string; + } + interface Data { + currentUser: { + firstName: string; + }; + } + interface Variables { + id: string; + } + + class Element extends React.Component< + ChildProps, + { thing: number } + > { + state = { thing: 1 }; + + static getDerivedStateFromProps() { + return { + thing: 2, }; } - interface Variables { - id: string; - } - class Element extends React.Component< - ChildProps, - { thing: number } - > { - state = { thing: 1 }; + render() { + const { data } = this.props; + expect(this.state.thing).toBe(2); + return ( +
+ {!data || data.loading || !data.currentUser ? + "loading" + : data.currentUser.firstName} +
+ ); + } + } - static getDerivedStateFromProps() { - return { - thing: 2, - }; - } + const ElementWithData = graphql(query)(Element); - render() { - const { data } = this.props; - expect(this.state.thing).toBe(2); - return ( -
- {!data || data.loading || !data.currentUser ? - "loading" - : data.currentUser.firstName} -
- ); - } - } + const app = ( + + + + ); - const ElementWithData = graphql(query)(Element); + await getDataFromTree(app); - const app = ( - - - - ); - - getDataFromTree(app) - .then(() => { - const initialState = cache.extract(); - expect(initialState).toBeTruthy(); - expect( - initialState.ROOT_QUERY!['currentUser({"id":"1"})'] - ).toBeTruthy(); - resolve(); - }) - .catch(console.error); - } - ); + const initialState = cache.extract(); + expect(initialState).toBeTruthy(); + expect(initialState.ROOT_QUERY!['currentUser({"id":"1"})']).toBeTruthy(); + }); it("should correctly initialize an empty state to null", () => { class Element extends React.Component { @@ -651,7 +643,7 @@ describe("SSR", () => { return getDataFromTree(); }); - itAsync("should allow prepping state from props", (resolve, reject) => { + it("should allow prepping state from props", async () => { const query = gql` query user($id: ID) { currentUser(id: $id) { @@ -730,16 +722,11 @@ describe("SSR", () => {
); - getDataFromTree(app) - .then(() => { - const initialState = apolloClient.cache.extract(); - expect(initialState).toBeTruthy(); - expect( - initialState.ROOT_QUERY!['currentUser({"id":"1"})'] - ).toBeTruthy(); - resolve(); - }) - .catch(console.error); + await getDataFromTree(app); + + const initialState = apolloClient.cache.extract(); + expect(initialState).toBeTruthy(); + expect(initialState.ROOT_QUERY!['currentUser({"id":"1"})']).toBeTruthy(); }); it("shouldn't run queries if ssr is turned to off", () => { diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index 8e81130201f..2ac84fb45b8 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -19,7 +19,6 @@ import { } from "../../../core"; import { InMemoryCache } from "../../../cache"; import { - itAsync, MockedProvider, MockSubscriptionLink, mockSingleLink, @@ -1500,7 +1499,7 @@ describe("useMutation Hook", () => { await waitFor(() => expect(variablesMatched).toBe(true)); }); - itAsync("should be called with the provided context", (resolve, reject) => { + it("should be called with the provided context", async () => { const context = { id: 3 }; const variables = { @@ -1544,13 +1543,13 @@ describe("useMutation Hook", () => { ); - return waitFor(() => { + await waitFor(() => { expect(foundContext).toBe(true); - }).then(resolve, reject); + }); }); describe("If context is not provided", () => { - itAsync("should be undefined", (resolve, reject) => { + it("should be undefined", async () => { const variables = { description: "Get milk!", }; @@ -1587,92 +1586,89 @@ describe("useMutation Hook", () => { ); - return waitFor(() => { + await waitFor(() => { expect(checkedContext).toBe(true); - }).then(resolve, reject); + }); }); }); }); describe("Optimistic response", () => { - itAsync( - "should support optimistic response handling", - async (resolve, reject) => { - const optimisticResponse = { - __typename: "Mutation", - createTodo: { - id: 1, - description: "TEMPORARY", - priority: "High", - __typename: "Todo", - }, - }; + it("should support optimistic response handling", async () => { + const optimisticResponse = { + __typename: "Mutation", + createTodo: { + id: 1, + description: "TEMPORARY", + priority: "High", + __typename: "Todo", + }, + }; - const variables = { - description: "Get milk!", - }; + const variables = { + description: "Get milk!", + }; - const mocks = [ - { - request: { - query: CREATE_TODO_MUTATION, - variables, - }, - result: { data: CREATE_TODO_RESULT }, + const mocks = [ + { + request: { + query: CREATE_TODO_MUTATION, + variables, }, - ]; + result: { data: CREATE_TODO_RESULT }, + }, + ]; - const link = mockSingleLink(...mocks).setOnError(reject); - const cache = new InMemoryCache(); - const client = new ApolloClient({ - cache, - link, - }); + const link = mockSingleLink(...mocks); + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link, + }); - let renderCount = 0; - const Component = () => { - const [createTodo, { loading, data }] = useMutation( - CREATE_TODO_MUTATION, - { optimisticResponse } - ); + let renderCount = 0; + const Component = () => { + const [createTodo, { loading, data }] = useMutation( + CREATE_TODO_MUTATION, + { optimisticResponse } + ); - switch (renderCount) { - case 0: - expect(loading).toBeFalsy(); - expect(data).toBeUndefined(); - void createTodo({ variables }); - - const dataInStore = client.cache.extract(true); - expect(dataInStore["Todo:1"]).toEqual( - optimisticResponse.createTodo - ); - - break; - case 1: - expect(loading).toBeTruthy(); - expect(data).toBeUndefined(); - break; - case 2: - expect(loading).toBeFalsy(); - expect(data).toEqual(CREATE_TODO_RESULT); - break; - default: - } - renderCount += 1; - return null; - }; + switch (renderCount) { + case 0: + expect(loading).toBeFalsy(); + expect(data).toBeUndefined(); + void createTodo({ variables }); - render( - - - - ); + const dataInStore = client.cache.extract(true); + expect(dataInStore["Todo:1"]).toEqual( + optimisticResponse.createTodo + ); + + break; + case 1: + expect(loading).toBeTruthy(); + expect(data).toBeUndefined(); + break; + case 2: + expect(loading).toBeFalsy(); + expect(data).toEqual(CREATE_TODO_RESULT); + break; + default: + } + renderCount += 1; + return null; + }; - return waitFor(() => { - expect(renderCount).toBe(3); - }).then(resolve, reject); - } - ); + render( + + + + ); + + await waitFor(() => { + expect(renderCount).toBe(3); + }); + }); it("should be called with the provided context", async () => { const optimisticResponse = { diff --git a/src/react/hooks/__tests__/useReactiveVar.test.tsx b/src/react/hooks/__tests__/useReactiveVar.test.tsx index c78d7e6ec80..84cb17cb309 100644 --- a/src/react/hooks/__tests__/useReactiveVar.test.tsx +++ b/src/react/hooks/__tests__/useReactiveVar.test.tsx @@ -1,7 +1,6 @@ import React, { StrictMode, useEffect } from "react"; import { screen, render, waitFor, act } from "@testing-library/react"; -import { itAsync } from "../../../testing"; import { makeVar } from "../../../core"; import { useReactiveVar } from "../useReactiveVar"; @@ -47,92 +46,87 @@ describe("useReactiveVar Hook", () => { }); }); - itAsync( - "works when two components share a variable", - async (resolve, reject) => { - const counterVar = makeVar(0); - - let parentRenderCount = 0; - function Parent() { - const count = useReactiveVar(counterVar); + it("works when two components share a variable", async () => { + const counterVar = makeVar(0); - switch (++parentRenderCount) { - case 1: - expect(count).toBe(0); - break; - case 2: - expect(count).toBe(1); - break; - case 3: - expect(count).toBe(11); - break; - default: - reject(`too many (${parentRenderCount}) parent renders`); - } + let parentRenderCount = 0; + function Parent() { + const count = useReactiveVar(counterVar); - return ; + switch (++parentRenderCount) { + case 1: + expect(count).toBe(0); + break; + case 2: + expect(count).toBe(1); + break; + case 3: + expect(count).toBe(11); + break; + default: + throw new Error(`too many (${parentRenderCount}) parent renders`); } - let childRenderCount = 0; - function Child() { - const count = useReactiveVar(counterVar); + return ; + } - switch (++childRenderCount) { - case 1: - expect(count).toBe(0); - break; - case 2: - expect(count).toBe(1); - break; - case 3: - expect(count).toBe(11); - break; - default: - reject(`too many (${childRenderCount}) child renders`); - } + let childRenderCount = 0; + function Child() { + const count = useReactiveVar(counterVar); - return null; + switch (++childRenderCount) { + case 1: + expect(count).toBe(0); + break; + case 2: + expect(count).toBe(1); + break; + case 3: + expect(count).toBe(11); + break; + default: + throw new Error(`too many (${childRenderCount}) child renders`); } - render(); + return null; + } - await waitFor(() => { - expect(parentRenderCount).toBe(1); - }); + render(); - await waitFor(() => { - expect(childRenderCount).toBe(1); - }); + await waitFor(() => { + expect(parentRenderCount).toBe(1); + }); - expect(counterVar()).toBe(0); - act(() => { - counterVar(1); - }); + await waitFor(() => { + expect(childRenderCount).toBe(1); + }); - await waitFor(() => { - expect(parentRenderCount).toBe(2); - }); - await waitFor(() => { - expect(childRenderCount).toBe(2); - }); + expect(counterVar()).toBe(0); + act(() => { + counterVar(1); + }); - expect(counterVar()).toBe(1); - act(() => { - counterVar(counterVar() + 10); - }); + await waitFor(() => { + expect(parentRenderCount).toBe(2); + }); + await waitFor(() => { + expect(childRenderCount).toBe(2); + }); - await waitFor(() => { - expect(parentRenderCount).toBe(3); - }); - await waitFor(() => { - expect(childRenderCount).toBe(3); - }); + expect(counterVar()).toBe(1); + act(() => { + counterVar(counterVar() + 10); + }); - expect(counterVar()).toBe(11); + await waitFor(() => { + expect(parentRenderCount).toBe(3); + }); + await waitFor(() => { + expect(childRenderCount).toBe(3); + }); - resolve(); - } - ); + expect(counterVar()).toBe(11); + }); it("does not update if component has been unmounted", async () => { const counterVar = makeVar(0); @@ -252,7 +246,7 @@ describe("useReactiveVar Hook", () => { }); }); - itAsync("works with strict mode", async (resolve, reject) => { + it("works with strict mode", async () => { const counterVar = makeVar(0); const mock = jest.fn(); @@ -289,94 +283,84 @@ describe("useReactiveVar Hook", () => { expect(mock).toHaveBeenNthCalledWith(2, 1); } }); - - resolve(); }); - itAsync( - "works with multiple synchronous calls", - async (resolve, reject) => { - const counterVar = makeVar(0); - function Component() { - const count = useReactiveVar(counterVar); + it("works with multiple synchronous calls", async () => { + const counterVar = makeVar(0); + function Component() { + const count = useReactiveVar(counterVar); - return
{count}
; - } + return
{count}
; + } - render(); - void Promise.resolve().then(() => { - counterVar(1); - counterVar(2); - counterVar(3); - counterVar(4); - counterVar(5); - counterVar(6); - counterVar(7); - counterVar(8); - counterVar(9); - counterVar(10); - }); - - await waitFor(() => { - expect(screen.getAllByText("10")).toHaveLength(1); - }); - - resolve(); + render(); + void Promise.resolve().then(() => { + counterVar(1); + counterVar(2); + counterVar(3); + counterVar(4); + counterVar(5); + counterVar(6); + counterVar(7); + counterVar(8); + counterVar(9); + counterVar(10); + }); + + await waitFor(() => { + expect(screen.getAllByText("10")).toHaveLength(1); + }); + }); + + it("should survive many rerenderings despite racing asynchronous updates", (done) => { + const rv = makeVar(0); + + function App() { + const value = useReactiveVar(rv); + return ( +
+

{value}

+
+ ); } - ); - - itAsync( - "should survive many rerenderings despite racing asynchronous updates", - (resolve, reject) => { - const rv = makeVar(0); - - function App() { - const value = useReactiveVar(rv); - return ( -
-

{value}

-
- ); - } - const goalCount = 1000; - let updateCount = 0; - let stopped = false; - - function spam() { - if (stopped) return; - try { - if (++updateCount <= goalCount) { - act(() => { - rv(updateCount); - setTimeout(spam, Math.random() * 10); - }); - } else { - stopped = true; - expect(rv()).toBe(goalCount); - screen - .findByText(String(goalCount)) - .then((element) => { - expect(element.nodeName.toLowerCase()).toBe("h1"); - }) - .then(resolve, reject); - } - } catch (e) { + const goalCount = 1000; + let updateCount = 0; + let stopped = false; + + function spam() { + if (stopped) return; + try { + if (++updateCount <= goalCount) { + act(() => { + rv(updateCount); + setTimeout(spam, Math.random() * 10); + }); + } else { stopped = true; - reject(e); + expect(rv()).toBe(goalCount); + void screen + .findByText(String(goalCount)) + .then((element) => { + expect(element.nodeName.toLowerCase()).toBe("h1"); + }) + .then(done); } + } catch (e) { + stopped = true; + throw e; } - spam(); - spam(); - spam(); - spam(); - - render( - - - - ); } - ); + spam(); + spam(); + spam(); + spam(); + + render( + + + + ); + }); }); }); diff --git a/src/testing/internal/ObservableStream.ts b/src/testing/internal/ObservableStream.ts index 63f550827c6..f6c53169b87 100644 --- a/src/testing/internal/ObservableStream.ts +++ b/src/testing/internal/ObservableStream.ts @@ -1,4 +1,7 @@ -import type { Observable } from "../../utilities/index.js"; +import type { + Observable, + ObservableSubscription, +} from "../../utilities/index.js"; import { ReadableStream } from "node:stream/web"; export interface TakeOptions { @@ -11,10 +14,12 @@ type ObservableEvent = export class ObservableStream { private reader: ReadableStreamDefaultReader>; + private subscription!: ObservableSubscription; + constructor(observable: Observable) { this.reader = new ReadableStream>({ - start(controller) { - observable.subscribe( + start: (controller) => { + this.subscription = observable.subscribe( (value) => controller.enqueue({ type: "next", value }), (error) => controller.enqueue({ type: "error", error }), () => controller.enqueue({ type: "complete" }) @@ -36,6 +41,10 @@ export class ObservableStream { ]); } + unsubscribe() { + this.subscription.unsubscribe(); + } + async takeNext(options?: TakeOptions): Promise { const event = await this.take(options); expect(event).toEqual({ type: "next", value: expect.anything() }); diff --git a/src/testing/matchers/toEmitError.ts b/src/testing/matchers/toEmitError.ts index 75e93aa56f2..f488e6f0de4 100644 --- a/src/testing/matchers/toEmitError.ts +++ b/src/testing/matchers/toEmitError.ts @@ -1,7 +1,15 @@ -import type { MatcherFunction } from "expect"; +import type { MatcherFunction, MatcherContext } from "expect"; import type { ObservableStream } from "../internal/index.js"; import type { TakeOptions } from "../internal/ObservableStream.js"; +function isErrorEqual(this: MatcherContext, expected: any, actual: any) { + if (typeof expected === "string" && actual instanceof Error) { + return actual.message === expected; + } + + return this.equals(expected, actual, this.customTesters); +} + export const toEmitError: MatcherFunction< [value?: any, options?: TakeOptions] > = async function (actual, expected, options) { @@ -15,9 +23,7 @@ export const toEmitError: MatcherFunction< try { const error = await stream.takeError(options); const pass = - expected === undefined ? true : ( - this.equals(expected, error, this.customTesters) - ); + expected === undefined ? true : isErrorEqual.call(this, expected, error); return { pass, @@ -37,7 +43,7 @@ export const toEmitError: MatcherFunction< "\n\n" + this.utils.printDiffOrStringify( expected, - error, + typeof expected === "string" ? error.message : error, "Expected", "Recieved", true diff --git a/src/utilities/observables/__tests__/asyncMap.ts b/src/utilities/observables/__tests__/asyncMap.ts index ce4227be45b..8f9d53071cd 100644 --- a/src/utilities/observables/__tests__/asyncMap.ts +++ b/src/utilities/observables/__tests__/asyncMap.ts @@ -1,6 +1,5 @@ import { Observable } from "../Observable"; import { asyncMap } from "../asyncMap"; -import { itAsync } from "../../../testing"; import { ObservableStream } from "../../../testing/internal"; const wait = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -19,106 +18,66 @@ function make1234Observable() { }); } -function rejectExceptions( - reject: (reason: any) => any, - fn: (...args: Args) => Ret -) { - return function () { - try { - // @ts-expect-error - return fn.apply(this, arguments); - } catch (error) { - reject(error); - } - } as typeof fn; -} - describe("asyncMap", () => { - itAsync("keeps normal results in order", (resolve, reject) => { + it("keeps normal results in order", async () => { const values: number[] = []; - const mapped: number[] = []; - asyncMap(make1234Observable(), (value) => { + const observable = asyncMap(make1234Observable(), (value) => { values.push(value); // Make earlier results take longer than later results. const delay = 100 - value * 10; return wait(delay).then(() => value * 2); - }).subscribe({ - next(mappedValue) { - mapped.push(mappedValue); - }, - error: reject, - complete: rejectExceptions(reject, () => { - expect(values).toEqual([1, 2, 3, 4]); - expect(mapped).toEqual([2, 4, 6, 8]); - resolve(); - }), }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue(2); + await expect(stream).toEmitValue(4); + await expect(stream).toEmitValue(6); + await expect(stream).toEmitValue(8); + await expect(stream).toComplete(); + + expect(values).toEqual([1, 2, 3, 4]); }); - itAsync("handles exceptions from mapping functions", (resolve, reject) => { - const triples: number[] = []; - asyncMap(make1234Observable(), (num) => { + it("handles exceptions from mapping functions", async () => { + const observable = asyncMap(make1234Observable(), (num) => { if (num === 3) throw new Error("expected"); return num * 3; - }).subscribe({ - next: rejectExceptions(reject, (triple) => { - expect(triple).toBeLessThan(9); - triples.push(triple); - }), - error: rejectExceptions(reject, (error) => { - expect(error.message).toBe("expected"); - expect(triples).toEqual([3, 6]); - resolve(); - }), }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue(3); + await expect(stream).toEmitValue(6); + await expect(stream).toEmitError(new Error("expected")); + }); + + it("handles rejected promises from mapping functions", async () => { + const observable = asyncMap(make1234Observable(), (num) => { + if (num === 3) return Promise.reject(new Error("expected")); + return num * 3; + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitValue(3); + await expect(stream).toEmitValue(6); + await expect(stream).toEmitError(new Error("expected")); }); - itAsync( - "handles rejected promises from mapping functions", - (resolve, reject) => { - const triples: number[] = []; - asyncMap(make1234Observable(), (num) => { - if (num === 3) return Promise.reject(new Error("expected")); + it("handles async exceptions from mapping functions", async () => { + const observable = asyncMap(make1234Observable(), (num) => + wait(10).then(() => { + if (num === 3) throw new Error("expected"); return num * 3; - }).subscribe({ - next: rejectExceptions(reject, (triple) => { - expect(triple).toBeLessThan(9); - triples.push(triple); - }), - error: rejectExceptions(reject, (error) => { - expect(error.message).toBe("expected"); - expect(triples).toEqual([3, 6]); - resolve(); - }), - }); - } - ); + }) + ); + const stream = new ObservableStream(observable); - itAsync( - "handles async exceptions from mapping functions", - (resolve, reject) => { - const triples: number[] = []; - asyncMap(make1234Observable(), (num) => - wait(10).then(() => { - if (num === 3) throw new Error("expected"); - return num * 3; - }) - ).subscribe({ - next: rejectExceptions(reject, (triple) => { - expect(triple).toBeLessThan(9); - triples.push(triple); - }), - error: rejectExceptions(reject, (error) => { - expect(error.message).toBe("expected"); - expect(triples).toEqual([3, 6]); - resolve(); - }), - }); - } - ); + await expect(stream).toEmitValue(3); + await expect(stream).toEmitValue(6); + await expect(stream).toEmitError(new Error("expected")); + }); - itAsync("handles exceptions from next functions", (resolve, reject) => { + it("handles exceptions from next functions", (done) => { const triples: number[] = []; asyncMap(make1234Observable(), (num) => { return num * 3; @@ -136,10 +95,10 @@ describe("asyncMap", () => { // expect(triples).toEqual([3, 6, 9]); // resolve(); // }), - complete: rejectExceptions(reject, () => { + complete: () => { expect(triples).toEqual([3, 6, 9, 12]); - resolve(); - }), + done(); + }, }); }); From 655d87d1f4130fea84bab3ed466ef327c526083c Mon Sep 17 00:00:00 2001 From: Maria Elisabeth Schreiber Date: Thu, 12 Dec 2024 13:09:11 -0700 Subject: [PATCH 09/17] Move redirects --- docs/source/_redirects | 108 ----------------------------------------- 1 file changed, 108 deletions(-) delete mode 100644 docs/source/_redirects diff --git a/docs/source/_redirects b/docs/source/_redirects deleted file mode 100644 index 689c408a089..00000000000 --- a/docs/source/_redirects +++ /dev/null @@ -1,108 +0,0 @@ -# Redirect all 3.0 beta docs to root -/v3.0-beta/* /docs/react/:splat - -# Redirect 2.x docs to v2 -/v2.4/* /docs/react/v2/ -/v2.5/* /docs/react/v2/ -/v2.6/* /docs/react/v2/:splat - -# Split out pagination article -/data/pagination/ /docs/react/pagination/overview/ - -# Remove 'Recompose patterns' article -/development-testing/recompose/ /docs/react/ - -# Client 3.0 changes -/api/apollo-client/ /docs/react/api/core/ApolloClient/ -/api/react-hooks/ /docs/react/api/react/hooks/ -/api/react-testing/ /docs/react/api/react/testing/ -/api/react-components/ /docs/react/api/react/components/ -/api/react-hoc/ /docs/react/api/react/hoc/ -/api/react-ssr/ /docs/react/api/react/ssr/ -/api/react-common/ /docs/react/api/react/hooks/ - -# Apollo Client Information Architecture refresh -# https://github.com/apollographql/apollo-client/pull/5321 -/features/error-handling/ /docs/react/data/error-handling/ -/advanced/fragments/ /docs/react/data/fragments/ -/essentials/mutations/ /docs/react/data/mutations/ -/features/pagination/ /docs/react/data/pagination/ -/essentials/queries/ /docs/react/data/queries/ -/advanced/subscriptions/ /docs/react/data/subscriptions/ -/recipes/client-schema-mocking/ /docs/react/development-testing/client-schema-mocking/ -/features/developer-tooling/ /docs/react/development-testing/developer-tooling/ -/recipes/recompose/ /docs/react/ -/recipes/static-typing/ /docs/react/development-testing/static-typing/ -/recipes/testing/ /docs/react/development-testing/testing/ -/essentials/get-started/ /docs/react/get-started/ -/integrations/ /docs/react/integrations/integrations/ -/recipes/meteor/ /docs/react/integrations/meteor/ -/recipes/react-native/ /docs/react/integrations/react-native/ -/recipes/webpack/ /docs/react/integrations/webpack/ -/advanced/caching/ /docs/react/caching/cache-configuration/ -/essentials/local-state/ /docs/react/data/local-state/ -/advanced/boost-migration/ /docs/react/migrating/boost-migration/ -/hooks-migration/ /docs/react/migrating/hooks-migration/ -/recipes/authentication/ /docs/react/networking/authentication/ -/advanced/network-layer/ /docs/react/networking/network-layer/ -/recipes/babel/ /docs/react/performance/babel/ -/features/optimistic-ui/ /docs/react/performance/optimistic-ui/ -/recipes/performance/ /docs/react/performance/performance/ -/features/server-side-rendering/ /docs/react/performance/server-side-rendering/ - -# React Apollo 2.0 - Basics -# https://github.com/apollographql/apollo-client/pull/3097 -/basics/setup.html /docs/react/get-started/ -/essentials/get-started.html /docs/react/get-started/ -/basics/queries.html /docs/react/data/queries/ -/essentials/queries.html /docs/react/data/queries/ -/basics/mutations.html /docs/react/data/mutations/ -/essentials/mutations.html /docs/react/data/mutations/ -/basics/network-layer.html /docs/react/networking/network-layer/ -/advanced/network-layer.html /docs/react/networking/network-layer/ -/basics/caching.html /docs/react/caching/cache-configuration/ -/advanced/caching.html /docs/react/caching/cache-configuration/ - -# React Apollo 2.0 - Features -# https://github.com/apollographql/apollo-client/pull/3097 -/features/caching.html /docs/react/caching/cache-configuration/ -/advanced/caching.html /docs/react/caching/cache-configuration/ -/features/cache-updates.html /docs/react/caching/cache-configuration/ -/advanced/caching.html /docs/react/caching/cache-configuration/ -/features/fragments.html /docs/react/data/fragments/ -/advanced/fragments.html /docs/react/data/fragments/ -/features/subscriptions.html /docs/react/data/subscriptions/ -/advanced/subscriptions.html /docs/react/data/subscriptions/ -/features/react-native.html /docs/react/integrations/react-native/ -/recipes/react-native.html /docs/react/integrations/react-native/ -/features/static-typing.html /docs/react/development-testing/static-typing/ -/recipes/static-typing.html /docs/react/development-testing/static-typing/ -/features/error-handling.html /docs/react/data/error-handling/ - -# React Apollo 2.0 - Recipes -# https://github.com/apollographql/apollo-client/pull/3097 -/recipes/query-splitting.html /docs/react/performance/performance/ -/features/performance.html#query-splitting /docs/react/performance/performance/ -/recipes/pagination.html /docs/react/data/pagination/ -/features/pagination.html /docs/react/data/pagination/ -/recipes/prefetching.html /docs/react/performance/performance/ -/features/performance.html#prefetching /docs/react/performance/performance/ -/recipes/server-side-rendering.html /docs/react/performance/server-side-rendering/ -/features/server-side-rendering.html /docs/react/performance/server-side-rendering/ -/recipes/fragment-matching.html /docs/react/data/fragments/ -/advanced/fragments.html /docs/react/data/fragments/ - -# Ported from old _config.yml file -/essentials/get-started.html#api /docs/react/get-started/ -/api/react-apollo.html /docs/react/get-started/ -/essentials/queries.html#api /docs/react/data/queries/#options -docs/react/api/react-apollo.html#graphql-query-options /docs/react/data/queries/#options -/basics/mutations.html#api /docs/react/basics/mutations.html -/recipes/simple-example.html /docs/react/get-started/ -docs/react/essentials/get-started.html /docs/react/get-started/ -/api/apollo-client.html#FetchPolicy /docs/react/api/core/ApolloClient/#example-defaultoptions-object -docs/react/api/react-apollo.html#graphql-config-options-fetchPolicy /docs/react/api/core/ApolloClient/#example-defaultoptions-object -/api/apollo-client.html#ErrorPolicy /docs/react/api/core/ApolloClient/#example-defaultoptions-object -docs/react/api/react-apollo.html#graphql-config-options-errorPolicy /docs/react/api/core/ApolloClient/#example-defaultoptions-object -/features/performance.html /docs/react/performance/performance/ -/recipes/performance.html /docs/react/performance/performance/ From 07907ea2af9838637738ae621c219f6c8e9d877f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 12 Dec 2024 16:25:27 -0700 Subject: [PATCH 10/17] Fix incorrect config for client preset in docs (#12218) --- docs/source/data/fragments.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/data/fragments.mdx b/docs/source/data/fragments.mdx index 3a9627fe64c..0faf4fcca73 100644 --- a/docs/source/data/fragments.mdx +++ b/docs/source/data/fragments.mdx @@ -1156,10 +1156,12 @@ const config: CodegenConfig = { // ... // disables the incompatible GraphQL Codegen fragment masking feature fragmentMasking: false, - inlineFragmentTypes: "mask", customDirectives: { apolloUnmask: true } + }, + config: { + inlineFragmentTypes: "mask", } } } From ed3eed70104f500ff8233d2137754b553d5d57f5 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 16 Dec 2024 10:23:11 +0100 Subject: [PATCH 11/17] link to the VSCode devtools from the react native page (#12220) --- docs/source/integrations/react-native.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/source/integrations/react-native.md b/docs/source/integrations/react-native.md index 3990bdffde4..78ea0eb8897 100644 --- a/docs/source/integrations/react-native.md +++ b/docs/source/integrations/react-native.md @@ -38,7 +38,15 @@ For more information on setting up Apollo Client, see [Getting started](../get-s ## Apollo Client Devtools -#### 1. Using [React Native Debugger](https://github.com/jhen0409/react-native-debugger) +#### 1. Using the VS Code [Apollo GraphQL extension](https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo) + +Apollo Client Devtools in a VS Code panel + +The Apollo GraphQL VSCode extension comes with the Apollo Client Devtools bundled, and these can be used with React Native. + +See [Developer tools - Apollo Client Devtools in VS Code](../development-testing/developer-tooling/#apollo-client-devtools-in-vs-code) for setup instructions. + +#### 2. Using [React Native Debugger](https://github.com/jhen0409/react-native-debugger) The React Native Debugger supports the [Apollo Client Devtools](../development-testing/developer-tooling/#apollo-client-devtools): @@ -46,7 +54,7 @@ The React Native Debugger supports the [Apollo Client Devtools](../development-t 2. Enable "Debug JS Remotely" in your app. 3. If you don't see the Developer Tools panel or the Apollo tab is missing from it, toggle the Developer Tools by right-clicking anywhere and selecting **Toggle Developer Tools**. -#### 2. Using [Flipper](https://fbflipper.com/) +#### 3. Using [Flipper](https://fbflipper.com/) A community plugin called [React Native Apollo devtools](https://github.com/razorpay/react-native-apollo-devtools) is available for Flipper, which supports viewing cache data. From b41a6ae934453e51e31a7a888eb30434fd7dcb43 Mon Sep 17 00:00:00 2001 From: Maria Elisabeth Schreiber Date: Mon, 16 Dec 2024 12:14:37 -0700 Subject: [PATCH 12/17] Remove Discord mentions --- .github/ISSUE_TEMPLATE/question-discussion.md | 1 - .github/workflows/lock.yml | 2 +- README.md | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/question-discussion.md b/.github/ISSUE_TEMPLATE/question-discussion.md index efb11c7a9e9..be195b9cd84 100644 --- a/.github/ISSUE_TEMPLATE/question-discussion.md +++ b/.github/ISSUE_TEMPLATE/question-discussion.md @@ -5,7 +5,6 @@ about: Questions / discussions are best posted in our community forums or StackO Need help or want to talk all things Apollo Client? Issues here are reserved for bugs, but one of the following resources should help: -* Apollo Discord server: https://discord.gg/graphos * Apollo GraphQL community forums: https://community.apollographql.com * StackOverflow (`apollo-client` tag): https://stackoverflow.com/questions/tagged/apollo-client * Apollo Feature Request repo: https://github.com/apollographql/apollo-feature-requests diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 5cc733c8cd7..56d4a35fd83 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -25,7 +25,7 @@ jobs: issue-comment: > This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - For general questions, we recommend using [StackOverflow](https://stackoverflow.com/questions/tagged/apollo-client) or our [discord server](https://discord.gg/graphos). + For general questions, we recommend using [StackOverflow](https://stackoverflow.com/questions/tagged/apollo-client) or our [Community Forum](https://community.apollographql.com/). pr-inactive-days: "30" exclude-any-pr-labels: "discussion" diff --git a/README.md b/README.md index 49c33c2fd06..d10076c425f 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@

Apollo Client

-[![npm version](https://badge.fury.io/js/%40apollo%2Fclient.svg)](https://badge.fury.io/js/%40apollo%2Fclient) [![Build Status](https://circleci.com/gh/apollographql/apollo-client.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-client) [![Join the community](https://img.shields.io/discourse/status?label=Join%20the%20community&server=https%3A%2F%2Fcommunity.apollographql.com)](https://community.apollographql.com) [![Join our Discord server](https://img.shields.io/discord/1022972389463687228.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square)](https://discord.gg/graphos) +[![npm version](https://badge.fury.io/js/%40apollo%2Fclient.svg)](https://badge.fury.io/js/%40apollo%2Fclient) [![Build Status](https://circleci.com/gh/apollographql/apollo-client.svg?style=svg)](https://circleci.com/gh/apollographql/apollo-client) [![Join the community](https://img.shields.io/discourse/status?label=Join%20the%20community&server=https%3A%2F%2Fcommunity.apollographql.com)](https://community.apollographql.com) --- -**Announcement:** +**Announcement:** Join 1000+ engineers at GraphQL Summit for talks, workshops, and office hours, Oct 8-10 in NYC. [Get your pass here ->](https://summit.graphql.com/?utm_campaign=github_federation_readme) --- From aa1ee9f3b99878960d2108fea2d805d54c5d42a7 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Dec 2024 13:26:06 +0100 Subject: [PATCH 13/17] add GH action to run tests against react@canary on a schedule (#12232) --- .github/workflows/scheduled-test-canary.yml | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/scheduled-test-canary.yml diff --git a/.github/workflows/scheduled-test-canary.yml b/.github/workflows/scheduled-test-canary.yml new file mode 100644 index 00000000000..639878253ba --- /dev/null +++ b/.github/workflows/scheduled-test-canary.yml @@ -0,0 +1,25 @@ +# a GitHub Action that once a day runs all tests from `main` and `release-*` branches +# with the latest `canary` and `experimental` release of `react` and `react-dom` + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tag: + - canary + - experimental + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - uses: bahmutov/npm-install@v1 + - run: npm install react@${{ matrix.tag }} react-dom@${{ matrix.tag }} + # tests can be flaky, this runs only once a day and we want to minimize false negatives - retry up to three times + - run: "parallel --line-buffer -j 1 --retries 3 'npm run test:ci -- --selectProjects ' ::: 'ReactDOM 19'" From 2fb7d7a3362b7d7296a5ebac8081cf0bfc1865eb Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 18 Dec 2024 17:03:21 +0100 Subject: [PATCH 14/17] canary test adjustments (#12233) * run on multiple branches, log installed versions, adjust test command * add `|| true` * different react version logging * allow inputs on dispatch * wording --- .github/workflows/scheduled-test-canary.yml | 25 ++++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/scheduled-test-canary.yml b/.github/workflows/scheduled-test-canary.yml index 639878253ba..42d5442d948 100644 --- a/.github/workflows/scheduled-test-canary.yml +++ b/.github/workflows/scheduled-test-canary.yml @@ -1,25 +1,38 @@ # a GitHub Action that once a day runs all tests from `main` and `release-*` branches # with the latest `canary` and `experimental` release of `react` and `react-dom` - +name: Scheduled React Canary Test on: schedule: - cron: "0 0 * * *" workflow_dispatch: + inputs: + branches: + description: "Branches to test" + required: true + default: '["main", "release-3.13", "release-4.0"]' + tags: + description: "React and React-DOM versions" + required: true + default: '["canary", "experimental"]' jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - tag: - - canary - - experimental + tag: ${{ fromJson(github.event_name == 'workflow_dispatch' && inputs.tags || '["canary", "experimental"]') }} + branch: ${{ fromJson(github.event_name == 'workflow_dispatch' && inputs.branches || '["main", "release-3.13", "release-4.0"]') }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} - uses: actions/setup-node@v4 with: node-version: 22.x - uses: bahmutov/npm-install@v1 - - run: npm install react@${{ matrix.tag }} react-dom@${{ matrix.tag }} + - run: | + npm install react@${{ matrix.tag }} react-dom@${{ matrix.tag }} # tests can be flaky, this runs only once a day and we want to minimize false negatives - retry up to three times - - run: "parallel --line-buffer -j 1 --retries 3 'npm run test:ci -- --selectProjects ' ::: 'ReactDOM 19'" + - run: | + node -e 'console.log("\n\nReact %s, React-DOM %s\n\n", require("react").version, require("react-dom").version)' + parallel --line-buffer -j 1 --retries 3 'npm test -- --logHeapUsage --selectProjects ' ::: 'ReactDOM 19' From 4334d30cc3fbedb4f736eff196c49a9f20a46704 Mon Sep 17 00:00:00 2001 From: Nicolas Charpentier Date: Thu, 19 Dec 2024 11:42:35 -0500 Subject: [PATCH 15/17] Compare `DocumentNode` used in `refetchQueries` as strings (#12236) Co-authored-by: Jerel Miller --- .changeset/gorgeous-sheep-knock.md | 5 + .size-limits.json | 4 +- src/core/QueryManager.ts | 40 +-- src/core/__tests__/QueryManager/index.ts | 298 ++++++++++++++++++++++- 4 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 .changeset/gorgeous-sheep-knock.md diff --git a/.changeset/gorgeous-sheep-knock.md b/.changeset/gorgeous-sheep-knock.md new file mode 100644 index 00000000000..7d62428c804 --- /dev/null +++ b/.changeset/gorgeous-sheep-knock.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix an issue with `refetchQueries` where comparing `DocumentNode`s internally by references could lead to an unknown query, even though the `DocumentNode` was indeed an active query—with a different reference. diff --git a/.size-limits.json b/.size-limits.json index c7b4947027f..54621796c0c 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41615, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34349 + "dist/apollo-client.min.cjs": 41639, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34381 } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index e61e123c5f2..066dc137de9 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -899,15 +899,19 @@ export class QueryManager { include: InternalRefetchQueriesInclude = "active" ) { const queries = new Map>(); - const queryNamesAndDocs = new Map(); + const queryNames = new Map(); + const queryNamesAndQueryStrings = new Map(); const legacyQueryOptions = new Set(); if (Array.isArray(include)) { include.forEach((desc) => { if (typeof desc === "string") { - queryNamesAndDocs.set(desc, false); + queryNames.set(desc, desc); + queryNamesAndQueryStrings.set(desc, false); } else if (isDocumentNode(desc)) { - queryNamesAndDocs.set(this.transform(desc), false); + const queryString = print(this.transform(desc)); + queryNames.set(queryString, getOperationName(desc)); + queryNamesAndQueryStrings.set(queryString, false); } else if (isNonNullObject(desc) && desc.query) { legacyQueryOptions.add(desc); } @@ -935,12 +939,12 @@ export class QueryManager { if ( include === "active" || - (queryName && queryNamesAndDocs.has(queryName)) || - (document && queryNamesAndDocs.has(document)) + (queryName && queryNamesAndQueryStrings.has(queryName)) || + (document && queryNamesAndQueryStrings.has(print(document))) ) { queries.set(queryId, oq); - if (queryName) queryNamesAndDocs.set(queryName, true); - if (document) queryNamesAndDocs.set(document, true); + if (queryName) queryNamesAndQueryStrings.set(queryName, true); + if (document) queryNamesAndQueryStrings.set(print(document), true); } } }); @@ -969,15 +973,21 @@ export class QueryManager { }); } - if (__DEV__ && queryNamesAndDocs.size) { - queryNamesAndDocs.forEach((included, nameOrDoc) => { + if (__DEV__ && queryNamesAndQueryStrings.size) { + queryNamesAndQueryStrings.forEach((included, nameOrQueryString) => { if (!included) { - invariant.warn( - typeof nameOrDoc === "string" ? - `Unknown query named "%s" requested in refetchQueries options.include array` - : `Unknown query %o requested in refetchQueries options.include array`, - nameOrDoc - ); + const queryName = queryNames.get(nameOrQueryString); + + if (queryName) { + invariant.warn( + `Unknown query named "%s" requested in refetchQueries options.include array`, + queryName + ); + } else { + invariant.warn( + `Unknown anonymous query requested in refetchQueries options.include array` + ); + } } }); } diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 5d6d9592bcc..1edd4e2c2f1 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -46,7 +46,7 @@ import wrap from "../../../testing/core/wrap"; import observableToPromise, { observableToPromiseAndSubscription, } from "../../../testing/core/observableToPromise"; -import { itAsync, wait } from "../../../testing/core"; +import { itAsync } from "../../../testing/core"; import { ApolloClient } from "../../../core"; import { mockFetchQuery } from "../ObservableQuery"; import { Concast, print } from "../../../utilities"; @@ -5156,6 +5156,151 @@ describe("QueryManager", () => { } ); + itAsync( + "should ignore (with warning) a document node in refetchQueries that has no active subscriptions", + (resolve, reject) => { + const mutation = gql` + mutation changeAuthorName { + changeAuthorName(newName: "Jack Smith") { + firstName + lastName + } + } + `; + const mutationData = { + changeAuthorName: { + firstName: "Jack", + lastName: "Smith", + }, + }; + const query = gql` + query getAuthors { + author { + firstName + lastName + } + } + `; + const data = { + author: { + firstName: "John", + lastName: "Smith", + }, + }; + const secondReqData = { + author: { + firstName: "Jane", + lastName: "Johnson", + }, + }; + const queryManager = mockQueryManager( + { + request: { query }, + result: { data }, + }, + { + request: { query }, + result: { data: secondReqData }, + }, + { + request: { query: mutation }, + result: { data: mutationData }, + } + ); + + const observable = queryManager.watchQuery({ query }); + return observableToPromise({ observable }, (result) => { + expect(result.data).toEqual(data); + }) + .then(() => { + // The subscription has been stopped already + return queryManager.mutate({ + mutation, + refetchQueries: [query], + }); + }) + .then(() => { + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + 'Unknown query named "%s" requested in refetchQueries options.include array', + "getAuthors" + ); + }) + .then(resolve, reject); + } + ); + + itAsync( + "should ignore (with warning) a document node containing an anonymous query in refetchQueries that has no active subscriptions", + (resolve, reject) => { + const mutation = gql` + mutation changeAuthorName { + changeAuthorName(newName: "Jack Smith") { + firstName + lastName + } + } + `; + const mutationData = { + changeAuthorName: { + firstName: "Jack", + lastName: "Smith", + }, + }; + const query = gql` + query { + author { + firstName + lastName + } + } + `; + const data = { + author: { + firstName: "John", + lastName: "Smith", + }, + }; + const secondReqData = { + author: { + firstName: "Jane", + lastName: "Johnson", + }, + }; + const queryManager = mockQueryManager( + { + request: { query }, + result: { data }, + }, + { + request: { query }, + result: { data: secondReqData }, + }, + { + request: { query: mutation }, + result: { data: mutationData }, + } + ); + + const observable = queryManager.watchQuery({ query }); + return observableToPromise({ observable }, (result) => { + expect(result.data).toEqual(data); + }) + .then(() => { + // The subscription has been stopped already + return queryManager.mutate({ + mutation, + refetchQueries: [query], + }); + }) + .then(() => { + expect(consoleWarnSpy).toHaveBeenLastCalledWith( + "Unknown anonymous query requested in refetchQueries options.include array" + ); + }) + .then(resolve, reject); + } + ); + it("also works with a query document and variables", async () => { const mutation = gql` mutation changeAuthorName($id: ID!) { @@ -5228,12 +5373,157 @@ describe("QueryManager", () => { ); expect(observable.getCurrentResult().data).toEqual(secondReqData); - await wait(10); + await expect(stream).not.toEmitAnything(); + }); - queryManager["queries"].forEach((_, queryId) => { - expect(queryId).not.toContain("legacyOneTimeQuery"); + it("also works with a query document node", async () => { + const mutation = gql` + mutation changeAuthorName($id: ID!) { + changeAuthorName(newName: "Jack Smith", id: $id) { + firstName + lastName + } + } + `; + const mutationData = { + changeAuthorName: { + firstName: "Jack", + lastName: "Smith", + }, + }; + const query = gql` + query getAuthors($id: ID!) { + author(id: $id) { + firstName + lastName + } + } + `; + const data = { + author: { + firstName: "John", + lastName: "Smith", + }, + }; + const secondReqData = { + author: { + firstName: "Jane", + lastName: "Johnson", + }, + }; + + const variables = { id: "1234" }; + const mutationVariables = { id: "2345" }; + const queryManager = mockQueryManager( + { + request: { query, variables }, + result: { data }, + delay: 10, + }, + { + request: { query, variables }, + result: { data: secondReqData }, + delay: 100, + }, + { + request: { query: mutation, variables: mutationVariables }, + result: { data: mutationData }, + delay: 10, + } + ); + const observable = queryManager.watchQuery({ query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitMatchedValue({ data }); + + await queryManager.mutate({ + mutation, + variables: mutationVariables, + refetchQueries: [query], }); + await expect(stream).toEmitMatchedValue( + { data: secondReqData }, + { timeout: 150 } + ); + expect(observable.getCurrentResult().data).toEqual(secondReqData); + + await expect(stream).not.toEmitAnything(); + }); + + it("also works with different references of a same query document node", async () => { + const mutation = gql` + mutation changeAuthorName($id: ID!) { + changeAuthorName(newName: "Jack Smith", id: $id) { + firstName + lastName + } + } + `; + const mutationData = { + changeAuthorName: { + firstName: "Jack", + lastName: "Smith", + }, + }; + const query = gql` + query getAuthors($id: ID!) { + author(id: $id) { + firstName + lastName + } + } + `; + const data = { + author: { + firstName: "John", + lastName: "Smith", + }, + }; + const secondReqData = { + author: { + firstName: "Jane", + lastName: "Johnson", + }, + }; + + const variables = { id: "1234" }; + const mutationVariables = { id: "2345" }; + const queryManager = mockQueryManager( + { + request: { query, variables }, + result: { data }, + delay: 10, + }, + { + request: { query, variables }, + result: { data: secondReqData }, + delay: 100, + }, + { + request: { query: mutation, variables: mutationVariables }, + result: { data: mutationData }, + delay: 10, + } + ); + const observable = queryManager.watchQuery({ query, variables }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitMatchedValue({ data }); + + await queryManager.mutate({ + mutation, + variables: mutationVariables, + // spread the query into a new object to simulate multiple instances + refetchQueries: [{ ...query }], + }); + + await expect(stream).toEmitMatchedValue( + { data: secondReqData }, + { timeout: 150 } + ); + expect(observable.getCurrentResult().data).toEqual(secondReqData); + await expect(stream).not.toEmitAnything(); }); From 79abfdc034a89014a6219eac7252eef5d1fdfd36 Mon Sep 17 00:00:00 2001 From: Maria Elisabeth Schreiber Date: Thu, 19 Dec 2024 11:11:13 -0700 Subject: [PATCH 16/17] Update .github/workflows/lock.yml Co-authored-by: Edward Huang --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 56d4a35fd83..2cb19258de2 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -25,7 +25,7 @@ jobs: issue-comment: > This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - For general questions, we recommend using [StackOverflow](https://stackoverflow.com/questions/tagged/apollo-client) or our [Community Forum](https://community.apollographql.com/). + For general questions, we recommend using our [Community Forum](https://community.apollographql.com/) or [Stack Overflow](https://stackoverflow.com/questions/tagged/apollo-client). pr-inactive-days: "30" exclude-any-pr-labels: "discussion" From db6a4427b89a3069a9094ea4e7db3982a771b31e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:55:08 -0700 Subject: [PATCH 17/17] Version Packages (#12237) Co-authored-by: github-actions[bot] --- .changeset/gorgeous-sheep-knock.md | 5 ----- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/gorgeous-sheep-knock.md diff --git a/.changeset/gorgeous-sheep-knock.md b/.changeset/gorgeous-sheep-knock.md deleted file mode 100644 index 7d62428c804..00000000000 --- a/.changeset/gorgeous-sheep-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apollo/client": patch ---- - -Fix an issue with `refetchQueries` where comparing `DocumentNode`s internally by references could lead to an unknown query, even though the `DocumentNode` was indeed an active query—with a different reference. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76411547510..adc45c38443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @apollo/client +## 3.12.4 + +### Patch Changes + +- [#12236](https://github.com/apollographql/apollo-client/pull/12236) [`4334d30`](https://github.com/apollographql/apollo-client/commit/4334d30cc3fbedb4f736eff196c49a9f20a46704) Thanks [@charpeni](https://github.com/charpeni)! - Fix an issue with `refetchQueries` where comparing `DocumentNode`s internally by references could lead to an unknown query, even though the `DocumentNode` was indeed an active query—with a different reference. + ## 3.12.3 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index c52adc1b1f4..6503dfb9530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apollo/client", - "version": "3.12.3", + "version": "3.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apollo/client", - "version": "3.12.3", + "version": "3.12.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1e58fef8c9b..7538bd3a651 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/client", - "version": "3.12.3", + "version": "3.12.4", "description": "A fully-featured caching GraphQL client.", "private": true, "keywords": [