diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 3ff92332c84..0acba0ebdd0 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -39,20 +39,34 @@ type AllFieldsModifier> = Modifier extends Observable> { + getCurrentResult: () => ApolloCache.WatchFragmentResult; + reobserve: (options: ApolloCache.WatchFragmentReobserveOptions) => void; + } + // (undocumented) + export type WatchFragmentFromValue = StoreObject | Reference | FragmentType> | string; export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; - from: StoreObject | Reference | FragmentType> | string; + from: ApolloCache.WatchFragmentFromValue | Array | null>; optimistic?: boolean; variables?: TVariables; } + // (undocumented) + export interface WatchFragmentReobserveOptions { + // (undocumented) + from: TData extends Array ? Array | string | null> : StoreObject | Reference | FragmentType | string; + } export type WatchFragmentResult = ({ complete: true; missing?: never; - } & GetDataState) | ({ + } & GetDataState) | { complete: false; - missing: MissingTree; - } & GetDataState); + missing?: MissingTree; + data: TData extends Array ? Array> : DataValue.Partial; + dataState: "partial"; + }; } // @public (undocumented) @@ -106,7 +120,20 @@ export abstract class ApolloCache { updateQuery(options: Cache_2.UpdateQueryOptions, update: (data: Unmasked | null) => Unmasked | null | void): Unmasked | null; // (undocumented) abstract watch(watch: Cache_2.WatchOptions): () => void; - watchFragment(options: ApolloCache.WatchFragmentOptions): Observable>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>; + }): ApolloCache.ObservableFragment>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array | null>; + }): ApolloCache.ObservableFragment | null>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions): ApolloCache.ObservableFragment>; // (undocumented) abstract write(write: Cache_2.WriteOptions): Reference | undefined; writeFragment({ data, fragment, fragmentName, variables, overwrite, id, broadcast, }: Cache_2.WriteFragmentOptions): Reference | undefined; diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index ab7effb8971..d1e95bc6eb2 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -224,6 +224,11 @@ export namespace ApolloClient { extensions?: Record; } // (undocumented) + export interface ObservableFragment extends Observable_2> { + getCurrentResult: () => ApolloClient.WatchFragmentResult; + reobserve: (options: ApolloCache.WatchFragmentReobserveOptions) => void; + } + // (undocumented) export interface Options { assumeImmutableResults?: boolean; cache: ApolloCache; @@ -295,7 +300,7 @@ export namespace ApolloClient { // (undocumented) export type WatchFragmentOptions = ApolloCache.WatchFragmentOptions; // (undocumented) - export type WatchFragmentResult = ApolloCache.WatchFragmentResult; + export type WatchFragmentResult = ApolloCache.WatchFragmentResult>; export type WatchQueryOptions = { fetchPolicy?: WatchQueryFetchPolicy; nextFetchPolicy?: WatchQueryFetchPolicy | ((this: WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); @@ -367,7 +372,17 @@ export class ApolloClient { subscribe(options: ApolloClient.SubscribeOptions): SubscriptionObservable>>; // (undocumented) version: string; - watchFragment(options: ApolloClient.WatchFragmentOptions): Observable_2>>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array; + }): ApolloClient.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array | null>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions): ApolloClient.ObservableFragment; watchQuery(options: ApolloClient.WatchQueryOptions): ObservableQuery; writeFragment(options: ApolloClient.WriteFragmentOptions): Reference_2 | undefined; writeQuery(options: ApolloClient.WriteQueryOptions): Reference_2 | undefined; @@ -1140,7 +1155,7 @@ export type WatchQueryOptions = FromPrimitive | Array>; + +// Warning: (ae-forgotten-export) The symbol "FromPrimitive_2" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type From_2 = FromPrimitive_2 | Array>; + // @public (undocumented) -type From = StoreObject_2 | Reference_2 | FragmentType> | string | null; +type FromPrimitive = StoreObject | Reference | FragmentType> | string | null; + +// @public (undocumented) +type FromPrimitive_2 = StoreObject_2 | Reference_2 | FragmentType> | string | null; // @public (undocumented) export function getApolloContext(): ReactTypes.Context; @@ -388,6 +401,21 @@ export namespace useBackgroundQuery { // @public @deprecated (undocumented) export type UseBackgroundQueryResult = useBackgroundQuery.Result; +// @public +export function useFragment(options: useFragment.Options & { + from: Array>>; +}): useFragment.Result>; + +// @public +export function useFragment(options: useFragment.Options & { + from: Array; +}): useFragment.Result>; + +// @public +export function useFragment(options: useFragment.Options & { + from: Array>; +}): useFragment.Result>; + // @public export function useFragment(options: useFragment.Options): useFragment.Result; @@ -426,7 +454,7 @@ export namespace useFragment { client?: ApolloClient; fragment: DocumentNode_2 | TypedDocumentNode_2; fragmentName?: string; - from: StoreObject | Reference | FragmentType> | string | null; + from: From; optimistic?: boolean; variables?: NoInfer_2; } @@ -434,10 +462,12 @@ export namespace useFragment { export type Result = ({ complete: true; missing?: never; - } & GetDataState, "complete">) | ({ + } & GetDataState, "complete">) | { complete: false; missing?: MissingTree; - } & GetDataState, "partial">); + data: TData extends Array ? Array> : DataValue.Partial; + dataState: "partial"; + }; } // @public @deprecated (undocumented) @@ -912,9 +942,24 @@ export namespace useSubscription { } } +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array>>; +}): useSuspenseFragment.Result>; + +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array; +}): useSuspenseFragment.Result>; + +// @public +export function useSuspenseFragment(options: useSuspenseFragment.Options & { + from: Array>; +}): useSuspenseFragment.Result>; + // @public export function useSuspenseFragment(options: useSuspenseFragment.Options & { - from: NonNullable>; + from: NonNullable>; }): useSuspenseFragment.Result; // @public @@ -924,7 +969,7 @@ export function useSuspenseFragment(options: useSuspenseFragment.Options & { - from: From; + from: From_2; }): useSuspenseFragment.Result; // @public @@ -939,7 +984,7 @@ export namespace useSuspenseFragment { export type Options = { fragment: DocumentNode_2 | TypedDocumentNode_2; fragmentName?: string; - from: From; + from: From_2; optimistic?: boolean; client?: ApolloClient; }; @@ -1090,7 +1135,8 @@ export type UseSuspenseQueryResult = , fragment: DocumentNode, stringifiedVariables: string ]; @@ -50,7 +50,7 @@ export interface FragmentKey { class FragmentReference { // Warning: (ae-forgotten-export) The symbol "FragmentReferenceOptions" needs to be exported by the entry point index.d.ts constructor(client: ApolloClient, watchFragmentOptions: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | Array; }, options: FragmentReferenceOptions); // (undocumented) readonly key: FragmentKey; @@ -59,12 +59,14 @@ class FragmentReference>): () => void; // (undocumented) - readonly observable: Observable>; + readonly observable: ApolloClient.ObservableFragment; // Warning: (ae-forgotten-export) The symbol "FragmentRefPromise" needs to be exported by the entry point index.d.ts // // (undocumented) promise: FragmentRefPromise>; // (undocumented) + reobserve(options: ApolloCache.WatchFragmentReobserveOptions): void; + // (undocumented) retain(): () => void; } @@ -203,7 +205,7 @@ class SuspenseCache { // // (undocumented) getFragmentRef(cacheKey: FragmentCacheKey, client: ApolloClient, options: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | Array; }): FragmentReference; // (undocumented) getQueryRef["dataState"] = DataState["dataState"]>(cacheKey: CacheKey, createObservable: () => ObservableQuery): InternalQueryReference; diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 7042954b4a1..df5116df8d9 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -66,7 +66,7 @@ export function concatPagination(keyArgs?: KeyArgs): FieldPolic // Warning: (ae-forgotten-export) The symbol "DeepPartialObject" needs to be exported by the entry point index.d.ts // // @public -export type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; +export type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; // @public (undocumented) type DeepPartialMap = {} & Map, DeepPartial>; diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index 790b197514d..16c5fb7776c 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -18,7 +18,7 @@ import type { HKT } from '@apollo/client/utilities'; import type { InlineFragmentNode } from 'graphql'; import type { MaybeMasked } from '@apollo/client'; import type { NetworkStatus } from '@apollo/client'; -import type { Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import type { ObservableQuery } from '@apollo/client'; import type { Observer } from 'rxjs'; import type { OperationDefinitionNode } from 'graphql'; @@ -74,6 +74,9 @@ export const checkDocument: (doc: DocumentNode, expectedType?: OperationTypeNode // @internal @deprecated export function cloneDeep(value: T): T; +// @public +export function combineLatestBatched(observables: Array>): Observable; + // Warning: (ae-forgotten-export) The symbol "TupleToIntersection" needs to be exported by the entry point index.d.ts // // @internal @deprecated diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index ddcaa569a04..65272d90ef5 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -37,21 +37,36 @@ type AllFieldsModifier> = Modifier extends Observable> { + getCurrentResult: () => ApolloCache.WatchFragmentResult; + reobserve: (options: ApolloCache.WatchFragmentReobserveOptions) => void; + } + // Warning: (ae-forgotten-export) The symbol "NoInfer_2" needs to be exported by the entry point index.d.ts + // + // (undocumented) + export type WatchFragmentFromValue = StoreObject | Reference | FragmentType> | string; export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; - // Warning: (ae-forgotten-export) The symbol "NoInfer_2" needs to be exported by the entry point index.d.ts - from: StoreObject | Reference | FragmentType> | string; + from: ApolloCache.WatchFragmentFromValue | Array | null>; optimistic?: boolean; variables?: TVariables; } + // (undocumented) + export interface WatchFragmentReobserveOptions { + // (undocumented) + from: TData extends Array ? Array | string | null> : StoreObject | Reference | FragmentType | string; + } export type WatchFragmentResult = ({ complete: true; missing?: never; - } & GetDataState) | ({ + } & GetDataState) | { complete: false; - missing: MissingTree; - } & GetDataState); + missing?: MissingTree; + data: TData extends Array ? Array> : DataValue.Partial; + dataState: "partial"; + }; } // @public (undocumented) @@ -107,7 +122,20 @@ export abstract class ApolloCache { updateQuery(options: Cache_2.UpdateQueryOptions, update: (data: Unmasked | null) => Unmasked | null | void): Unmasked | null; // (undocumented) abstract watch(watch: Cache_2.WatchOptions): () => void; - watchFragment(options: ApolloCache.WatchFragmentOptions): Observable>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array>; + }): ApolloCache.ObservableFragment>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array; + }): ApolloCache.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions & { + from: Array | null>; + }): ApolloCache.ObservableFragment | null>>; + // (undocumented) + watchFragment(options: ApolloCache.WatchFragmentOptions): ApolloCache.ObservableFragment>; // (undocumented) abstract write(write: Cache_2.WriteOptions): Reference | undefined; writeFragment({ data, fragment, fragmentName, variables, overwrite, id, broadcast, }: Cache_2.WriteFragmentOptions): Reference | undefined; @@ -234,6 +262,11 @@ export namespace ApolloClient { extensions?: Record; } // (undocumented) + export interface ObservableFragment extends Observable> { + getCurrentResult: () => ApolloClient.WatchFragmentResult; + reobserve: (options: ApolloCache.WatchFragmentReobserveOptions) => void; + } + // (undocumented) export interface Options { assumeImmutableResults?: boolean; cache: ApolloCache; @@ -310,7 +343,7 @@ export namespace ApolloClient { // (undocumented) export type WatchFragmentOptions = ApolloCache.WatchFragmentOptions; // (undocumented) - export type WatchFragmentResult = ApolloCache.WatchFragmentResult; + export type WatchFragmentResult = ApolloCache.WatchFragmentResult>; export type WatchQueryOptions = { fetchPolicy?: WatchQueryFetchPolicy; nextFetchPolicy?: WatchQueryFetchPolicy | ((this: WatchQueryOptions, currentFetchPolicy: WatchQueryFetchPolicy, context: NextFetchPolicyContext) => WatchQueryFetchPolicy); @@ -383,7 +416,17 @@ export class ApolloClient { subscribe(options: ApolloClient.SubscribeOptions): SubscriptionObservable>>; // (undocumented) version: string; - watchFragment(options: ApolloClient.WatchFragmentOptions): Observable>>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array; + }): ApolloClient.ObservableFragment>; + // (undocumented) + watchFragment(options: ApolloClient.WatchFragmentOptions & { + from: Array | null>; + }): ApolloClient.ObservableFragment>; + watchFragment(options: ApolloClient.WatchFragmentOptions): ApolloClient.ObservableFragment; watchQuery(options: ApolloClient.WatchQueryOptions): ObservableQuery; writeFragment(options: ApolloClient.WriteFragmentOptions): Reference | undefined; writeQuery(options: ApolloClient.WriteQueryOptions): Reference | undefined; @@ -847,7 +890,7 @@ export namespace DataValue { // Warning: (ae-forgotten-export) The symbol "DeepPartialObject" needs to be exported by the entry point index.d.ts // // @public -type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; +type DeepPartial = T extends DeepPartialPrimitive ? T : T extends Map ? DeepPartialMap : T extends ReadonlyMap ? DeepPartialReadonlyMap : T extends Set ? DeepPartialSet : T extends ReadonlySet ? DeepPartialReadonlySet : T extends (...args: any[]) => unknown ? T | undefined : T extends object ? T extends (ReadonlyArray) ? TItem[] extends (T) ? readonly TItem[] extends T ? ReadonlyArray> : Array> : DeepPartialObject : DeepPartialObject : unknown; // Warning: (ae-forgotten-export) The symbol "DeepPartial" needs to be exported by the entry point index.d.ts // @@ -2718,13 +2761,13 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/core/cache.ts:94:9 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts +// src/cache/core/cache.ts:112:9 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:98:3 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts // src/cache/inmemory/policies.ts:167:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:134:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ApolloClient.ts:168:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts -// src/core/ApolloClient.ts:362:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts +// src/core/ApolloClient.ts:385:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:368:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:180:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/local-state/LocalState.ts:149:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts diff --git a/.changeset/old-singers-eat.md b/.changeset/old-singers-eat.md new file mode 100644 index 00000000000..84c8ffa53e3 --- /dev/null +++ b/.changeset/old-singers-eat.md @@ -0,0 +1,17 @@ +--- +"@apollo/client": minor +--- + +Add support for arrays with `useFragment`, `useSuspenseFragment`, and `client.watchFragment`. This allows the ability to use a fragment to watch multiple entities in the cache. Passing an array to `from` will return `data` as an array where each array index corresponds to the index in the `from` array. + +```ts +function MyComponent() { + const result = useFragment({ + fragment, + from: [item1, item2, item3] + }); + + // `data` is an array with 3 items + console.log(result); // { data: [{...}, {...}, {...}], dataState: "complete", complete: true } +} +``` diff --git a/.changeset/spicy-eels-switch.md b/.changeset/spicy-eels-switch.md new file mode 100644 index 00000000000..b65d6362a85 --- /dev/null +++ b/.changeset/spicy-eels-switch.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +`DeepPartial>` now returns `Array>` instead of `Array>`. diff --git a/.size-limits.json b/.size-limits.json index 6671f69ef70..e4ef40079ee 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,6 +1,6 @@ { - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 44831, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 39452, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33875, - "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27756 + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 45538, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 40051, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 34564, + "import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 28352 } diff --git a/config/jest.config.ts b/config/jest.config.ts index 4cd48b1fd7c..8fded0c2cd4 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -48,6 +48,7 @@ const react17TestFileIgnoreList = [ // React 17 "src/testing/experimental/__tests__/createTestSchema.test.tsx", "src/react/hooks/__tests__/useSuspenseFragment.test.tsx", + "src/react/hooks/__tests__/useSuspenseFragment/*", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", "src/react/hooks/__tests__/useSuspenseQuery/*", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", diff --git a/docs/source/data/fragments.mdx b/docs/source/data/fragments.mdx index 8a58f2959cf..7f40c6518da 100644 --- a/docs/source/data/fragments.mdx +++ b/docs/source/data/fragments.mdx @@ -518,7 +518,7 @@ function List() { } ``` - + Instead of interpolating fragments within each query document, you can use Apollo Client's `createFragmentRegistry` method to pre-register named fragments with `InMemoryCache`. This allows Apollo Client to include the @@ -526,7 +526,7 @@ function List() { before the request is sent. For more information, see [Registering named fragments using `createFragmentRegistry`](#registering-named-fragments-using-createfragmentregistry). - + We can then use `useFragment` from within the `` component to create a live binding for each item by providing the `fragment` document, `fragmentName` and object reference via `from`. @@ -564,10 +564,10 @@ function Item(props) { - + You may omit the `fragmentName` option when your fragment definition only includes a single fragment. - + You may instead prefer to pass the whole `item` as a prop to the `Item` component. This makes the `from` option more concise. @@ -576,7 +576,7 @@ You may instead prefer to pass the whole `item` as a prop to the `Item` componen ```tsx function Item(props: { item: { __typename: "Item"; id: number } }) { const { complete, data } = useFragment({ - fragment: ItemFragment, + fragment: ITEM_FRAGMENT, fragmentName: "ItemFragment", from: props.item, }); @@ -608,6 +608,114 @@ function Item(props) { See the [API reference](../api/react/useFragment) for more details on the supported options. + + +### Working with arrays + + + +Sometimes your component might use a fragment to select fields for an array of items that are received from props. You can use the `useFragment` hook to watch for changes on each array item by providing the array to the `from` option. + +When you provide an array to the `from` option, the `data` property returned from `useFragment` is an array where each item corresponds to an item with the same index in the `from` option. If all of the items returned in `data` are complete, the `complete` property is set to `true` and the `dataState` property is set to `"complete"`. If at least one item in the array is incomplete, the `complete` property is set to `false` and the `dataState` property is set to `"partial"`. + + + +```tsx +function Items(props: { items: Array<{ __typename: "Item"; id: number }> }) { + const { data, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: props.items, + }); + + if (!complete) { + return null; + } + + return ( +
    + {data.map((item) => ( +
  • {item.text}
  • + ))} +
+ ); +} +``` + +```js +function Items(props) { + const { data, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: props.items, + }); + + if (!complete) { + return null; + } + + return ( +
    + {data.map((item) => ( +
  • {item.text}
  • + ))} +
+ ); +} +``` + +
+ + + +If the array provided to the `from` option is an empty array, the returned `data` is an empty array with the `complete` property set to `true` and `dataState` property set to `"complete"`. + + + +#### Handling `null` values + +Depending on the GraphQL schema, it's possible the array might contain `null` values. When `useFragment` is provided an array that contains `null` values to the `from` property, `useFragment` returns those items as `null` in the `data` property and treats these items as complete. This means if all non-`null` items in the array are also complete, the whole result is complete. + +```ts +const { data, dataState, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: [{ __typename: "Item", id: 1 }, { __typename: "Item", id: 2 }, null], +}); + +console.log({ data, dataState, complete }); +// { +// data: [ +// { __typename: "Item", id: 1, text: "..." }, +// { __typename: "Item", id: 2, text: "..." }, +// null +// ] +// dataState: "complete", +// complete: true +// } +``` + + + +If the `from` array contains `null` values for every item, the result returned from `useFragment` contains all `null` values, the `complete` property is set to `true`, and the `dataState` property is set to `"complete"`. + +```ts +const { data, dataState, complete } = useFragment({ + fragment: ITEM_FRAGMENT, + fragmentName: "ItemFragment", + from: [null, null, null], +}); + +console.log({ data, dataState, complete }); +// { +// data: [null, null, null], +// dataState: "complete", +// complete: true +// } +``` + + + ## `useSuspenseFragment` For those that have integrated with React [Suspense](https://react.dev/reference/react/Suspense), `useSuspenseFragment` is available as a drop-in replacement for `useFragment`. `useSuspenseFragment` works identically to `useFragment` but will suspend while `data` is incomplete. diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 2dd66642204..82141a6b4b0 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -431,6 +431,7 @@ Array [ "canonicalStringify", "checkDocument", "cloneDeep", + "combineLatestBatched", "compact", "createFragmentMap", "createFulfilledPromise", diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 3e98626633d..4f9cfccd4b9 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -1,13 +1,23 @@ import { WeakCache } from "@wry/caches"; +import { equal } from "@wry/equality"; import type { DocumentNode, FragmentDefinitionNode, InlineFragmentNode, } from "graphql"; import { wrap } from "optimism"; -import { Observable } from "rxjs"; +import { + BehaviorSubject, + filter, + map, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; import type { + DataValue, GetDataState, OperationVariables, TypedDocumentNode, @@ -18,6 +28,7 @@ import { cacheSizes } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; import type { NoInfer } from "@apollo/client/utilities/internal"; import { + combineLatestBatched, equalByQuery, getApolloCacheMemoryInternals, getFragmentDefinition, @@ -33,6 +44,11 @@ import type { MissingTree } from "./types/common.js"; export type Transaction = (c: ApolloCache) => void; export declare namespace ApolloCache { + export type WatchFragmentFromValue = + | StoreObject + | Reference + | FragmentType> + | string; /** * Watched fragment options. */ @@ -55,7 +71,9 @@ export declare namespace ApolloCache { * * @docGroup 1. Required options */ - from: StoreObject | Reference | FragmentType> | string; + from: + | ApolloCache.WatchFragmentFromValue + | Array | null>; /** * Any variables that the GraphQL fragment may depend on. * @@ -89,10 +107,45 @@ export declare namespace ApolloCache { complete: true; missing?: never; } & GetDataState) - | ({ + | { complete: false; - missing: MissingTree; - } & GetDataState); + missing?: MissingTree; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#data:member} */ + data: TData extends Array ? + Array> + : DataValue.Partial; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#dataState:member} */ + dataState: "partial"; + }; + + export interface ObservableFragment + extends Observable> { + /** + * Return the current result for the fragment. + */ + getCurrentResult: () => ApolloCache.WatchFragmentResult; + + /** + * Re-evaluate the fragment against the updated `from` value. + * + * @example + * + * ```ts + * const observable = cache.watchFragment(options); + * + * observable.reobserve({ from: newFrom }); + * ``` + */ + reobserve: ( + options: ApolloCache.WatchFragmentReobserveOptions + ) => void; + } + + export interface WatchFragmentReobserveOptions { + from: TData extends Array ? + Array | string | null> + : StoreObject | Reference | FragmentType | string; + } } export abstract class ApolloCache { @@ -309,13 +362,49 @@ export abstract class ApolloCache { }); } + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array>; + } + ): ApolloCache.ObservableFragment>>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array; + } + ): ApolloCache.ObservableFragment>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions & { + from: Array | null>; + } + ): ApolloCache.ObservableFragment | null>>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloCache.WatchFragmentOptions + ): ApolloCache.ObservableFragment>; + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ public watchFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( options: ApolloCache.WatchFragmentOptions - ): Observable>> { + ): + | ApolloCache.ObservableFragment> + | ApolloCache.ObservableFragment>> { const { fragment, fragmentName, @@ -324,80 +413,269 @@ export abstract class ApolloCache { ...otherOptions } = options; const query = this.getFragmentDoc(fragment, fragmentName); - // While our TypeScript types do not allow for `undefined` as a valid - // `from`, its possible `useFragment` gives us an `undefined` since it - // calls` cache.identify` and provides that value to `from`. We are - // adding this fix here however to ensure those using plain JavaScript - // and using `cache.identify` themselves will avoid seeing the obscure - // warning. - const id = - typeof from === "undefined" || typeof from === "string" ? - from - : this.identify(from); - - if (__DEV__) { - const actualFragmentName = - fragmentName || getFragmentDefinition(fragment).name.value; - - if (!id) { - invariant.warn( - "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", - actualFragmentName - ); - } - } - const diffOptions: Cache.DiffOptions = { - ...otherOptions, - returnPartialData: true, - id, - query, - optimistic, - }; - - let latestDiff: Cache.DiffResult | undefined; - - return new Observable((observer) => { - return this.watch({ - ...diffOptions, + // We `wrap` this function to ensure we get back the same referentially + // equal options object for a given id. When calling `reobserve`, the + // `switchMap` below will fully unsubscribe from all observables in the + // current array before subscribing to observables in the new array. This + // means the observable cleanup function will run, then the setup function. + // Without `wrap`, we'd create a 2nd `cache.watch` call upon calling + // `reobserve` when the observable callback function runs. + const getWatchOptions = wrap((id: string) => { + let latestDiff: Cache.DiffResult | undefined; + + // This BehaviorSubject maintains the last emitted value so that a value + // can always be emitted immediately. Normally this is the responsiblity + // of immediate: true and the callback, but if cache.watch is called with + // the same options object, maybeBroadcastWatch doesn't execute the inner + // broadcast function. + const subject = new BehaviorSubject | undefined>( + undefined + ); + const options: Cache.WatchOptions = { + ...otherOptions, + returnPartialData: true, + id, + query, + optimistic, immediate: true, callback: (diff) => { - let data = diff.result; - - // TODO: Remove this once `watchFragment` supports `null` as valid - // value emitted - if (data === null) { - data = {} as any; - } - if ( // Always ensure we deliver the first result latestDiff && equalByQuery( query, { data: latestDiff.result }, - { data }, + { data: diff.result }, options.variables ) ) { return; } - const result = { - data, - dataState: diff.complete ? "complete" : "partial", - complete: !!diff.complete, - } as ApolloCache.WatchFragmentResult>; + latestDiff = diff; + subject.next(latestDiff); + }, + }; + + return { + // Ensure the initial undefined value is not emitted + observable: subject.pipe(filter(Boolean)), + options, + // mutable field to allow for watch to change this + timeoutId: undefined as NodeJS.Timeout | undefined, + }; + }); - if (diff.missing) { - result.missing = diff.missing.missing; + // For some reason, `wrap` doesn't work when used with the `watch` function, + // so we need to track it ourselves. + const watches = new Map< + string | null, + Observable> + >(); + const watch = (id: string | null) => { + if (watches.has(id)) { + return watches.get(id)!; + } + const observable = new Observable>((observer) => { + if (id === null) { + return observer.next({ result: null, complete: false }); + } + + const watch = getWatchOptions(id); + // This `clearTimeout` prevents the previous unsubscribe from the + // observable from unsubscribing from the cache watch for ids that + // stayed in the array after a reobserve call. + clearTimeout(watch.timeoutId); + + // Hijack the fact that `this.watch` uses a set on the options object to + // determine the unique number of watchers. This means calling + // `this.watch` multiple times with the same object will only result in + // a single watch. + // + // This, along with the timeout, ensures that calling `reobserve` with a + // new list will maintain existing watchers on items with the same id, + // rather than unsubscribing the from the watch, then resubscribing + // again. + const unsubscribe = this.watch(watch.options); + // It's important to subscribe to the observable after we call watch to + // ensure the watch callback updates the current value before + // subscription, otherwise we'll get an emit of a stale value followed + // immediately by an emit of the most recent value. + const subscription = watch.observable.subscribe(observer); + + return () => { + subscription.unsubscribe(); + watch.timeoutId = setTimeout(() => { + unsubscribe(); + getWatchOptions.forget(id); + watches.delete(id); + }); + }; + }).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + watches.set(id, observable); + + return observable; + }; + + const toStringIds = (from: ApolloCache.WatchFragmentOptions["from"]) => { + const fromArray = Array.isArray(from) ? from : [from]; + + return fromArray.map((value) => { + // While our TypeScript types do not allow for `undefined` as a valid + // `from`, its possible `useFragment` gives us an `undefined` since it + // calls` cache.identify` and provides that value to `from`. We are + // adding this fix here however to ensure those using plain JavaScript + // and using `cache.identify` themselves will avoid seeing the obscure + // warning. + const id = + ( + typeof value === "undefined" || + typeof value === "string" || + value === null + ) ? + value + : this.identify(value); + + if (__DEV__) { + const actualFragmentName = + fragmentName || getFragmentDefinition(fragment).name.value; + + if (id === undefined) { + invariant.warn( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + actualFragmentName + ); } + } - latestDiff = { ...diff, result: data } as Cache.DiffResult; - observer.next(result); - }, + return id as string | null; }); - }); + }; + + let currentResult: + | ApolloCache.WatchFragmentResult> + | ApolloCache.WatchFragmentResult>>; + function toResult(diffs: Array>) { + let result: + | ApolloCache.WatchFragmentResult> + | ApolloCache.WatchFragmentResult>>; + if (Array.isArray(from)) { + result = diffs.reduce( + (result, diff, idx) => { + const id = ids$.getValue()[idx]; + result.data.push(diff.result as any); + result.complete &&= id === null ? true : diff.complete; + result.dataState = result.complete ? "complete" : "partial"; + + if (diff.missing) { + result.missing ||= {}; + (result.missing as any)[idx] = diff.missing.missing; + } + + return result; + }, + { + data: [], + dataState: "complete", + complete: true, + } as ApolloCache.WatchFragmentResult>> + ); + } else { + const [diff] = diffs; + result = { + // Unfortunately we forgot to allow for `null` on watchFragment in 4.0 + // when `from` is a single record. As such, we need to fallback to {} + // when diff.result is null to maintain backwards compatibility. We + // should plan to change this in v5. + // + // NOTE: Using `from` with an array will maintain `null` properly + // without the need for a similar fallback since watchFragment with + // arrays is new functionality in v4. + data: diff.result ?? {}, + complete: !!diff.complete, + dataState: diff.complete ? "complete" : "partial", + } as ApolloCache.WatchFragmentResult>; + + if (diff.missing) { + result.missing = diff.missing.missing; + } + } + + if (!equal(currentResult, result)) { + currentResult = result; + } + + return currentResult; + } + + let subscribed = false; + const ids$ = new BehaviorSubject(toStringIds(from)); + + const observable = ids$.pipe( + switchMap((ids) => { + // combineLatestBatched completes immediately when given an empty array. + // We want to emit an empty array without the complete notification + // instead. + return ids.length > 0 ? + combineLatestBatched(ids.map(watch)) + : new Observable>>((observer) => + observer.next([]) + ); + }), + map(toResult), + shareReplay({ bufferSize: 1, refCount: true }), + tap({ + subscribe: () => (subscribed = true), + unsubscribe: () => (subscribed = false), + }) + ); + + return Object.assign(observable, { + reobserve: ( + options: + | ApolloCache.WatchFragmentReobserveOptions< + ApolloCache.WatchFragmentResult + > + | ApolloCache.WatchFragmentReobserveOptions< + Array> + > + ) => { + const isOrigArray = Array.isArray(from); + const isArray = Array.isArray(options.from); + + invariant( + isOrigArray === isArray, + isOrigArray ? + "Cannot change `from` option from array to non-array. Please provide `from` as an array." + : "Cannot change `from` option from non-array to array. Please provide `from` as an accepted non-array value." + ); + + ids$.next(toStringIds(options.from)); + }, + getCurrentResult: () => { + if (subscribed && currentResult) { + return currentResult as any; + } + + const diffs = ids$.getValue().map((id): Cache.DiffResult => { + if (id === null) { + return { result: null, complete: false }; + } + + return this.diff({ + id, + query, + returnPartialData: true, + optimistic, + variables: otherOptions.variables, + }); + }); + + return toResult(diffs); + }, + }) satisfies ApolloCache.ObservableFragment as any; } // Make sure we compute the same (===) fragment query document every diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 0089eb2b32e..67e420d2df5 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -346,7 +346,30 @@ export declare namespace ApolloClient { > = ApolloCache.WatchFragmentOptions; export type WatchFragmentResult = - ApolloCache.WatchFragmentResult; + ApolloCache.WatchFragmentResult>; + + export interface ObservableFragment + extends Observable> { + /** + * Return the current result for the fragment. + */ + getCurrentResult: () => ApolloClient.WatchFragmentResult; + + /** + * Re-evaluate the fragment against the updated `from` value. + * + * @example + * + * ```ts + * const observable = client.watchFragment(options); + * + * observable.reobserve({ from: newFrom }); + * ``` + */ + reobserve: ( + options: ApolloCache.WatchFragmentReobserveOptions + ) => void; + } /** * Watched query options. @@ -1114,42 +1137,105 @@ export class ApolloClient { * the cache to identify the fragment and optionally specify whether to react * to optimistic updates. */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array>; + } + ): ApolloClient.ObservableFragment>; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array; + } + ): ApolloClient.ObservableFragment>; + + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions & { + from: Array | null>; + } + ): ApolloClient.ObservableFragment>; + + /** {@inheritDoc @apollo/client!ApolloClient#watchFragment:member(1)} */ + public watchFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + options: ApolloClient.WatchFragmentOptions + ): ApolloClient.ObservableFragment; public watchFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( options: ApolloClient.WatchFragmentOptions - ): Observable>> { + ): + | ApolloClient.ObservableFragment + | ApolloClient.ObservableFragment> { const dataMasking = this.queryManager.dataMasking; + const observable = this.cache.watchFragment({ + ...options, + fragment: this.transform(options.fragment, dataMasking), + }); - return this.cache - .watchFragment({ - ...options, - fragment: this.transform(options.fragment, dataMasking), - }) - .pipe( - map((result) => { - // The transform will remove fragment spreads from the fragment - // document when dataMasking is enabled. The `maskFragment` function - // remains to apply warnings to fragments marked as - // `@unmask(mode: "migrate")`. Since these warnings are only applied - // in dev, we can skip the masking algorithm entirely for production. - if (__DEV__) { - if (dataMasking) { - const data = this.queryManager.maskFragment({ - ...options, - data: result.data, - }); - return { ...result, data } as ApolloClient.WatchFragmentResult< - MaybeMasked - >; - } - } + const mask = ( + result: + | ApolloClient.WatchFragmentResult> + | ApolloClient.WatchFragmentResult>> + ): + | ApolloClient.WatchFragmentResult> + | ApolloClient.WatchFragmentResult>> => { + // The transform will remove fragment spreads from the fragment + // document when dataMasking is enabled. The `mask` function + // remains to apply warnings to fragments marked as + // `@unmask(mode: "migrate")`. Since these warnings are only applied + // in dev, we can skip the masking algorithm entirely for production. + if (__DEV__) { + if (dataMasking) { + return { + ...result, + data: this.queryManager.maskFragment({ + ...options, + data: result.data, + }), + } as ApolloClient.WatchFragmentResult>; + } + } - return result as ApolloClient.WatchFragmentResult>; - }) - ); + return result as + | ApolloClient.WatchFragmentResult> + | ApolloClient.WatchFragmentResult>>; + }; + + let currentResult: + | ApolloClient.WatchFragmentResult> + | ApolloClient.WatchFragmentResult>>; + let stableMaskedResult: + | ApolloClient.WatchFragmentResult> + | ApolloClient.WatchFragmentResult>>; + + return Object.assign(observable.pipe(map(mask) as any), { + getCurrentResult: () => { + const result = observable.getCurrentResult(); + + if (result !== currentResult) { + currentResult = result as any; + stableMaskedResult = mask(currentResult); + } + + return stableMaskedResult; + }, + reobserve: observable.reobserve.bind(observable), + }) as ApolloClient.ObservableFragment; } /** diff --git a/src/core/__tests__/client.watchFragment/general.test.ts b/src/core/__tests__/client.watchFragment/general.test.ts new file mode 100644 index 00000000000..070cf4d3efc --- /dev/null +++ b/src/core/__tests__/client.watchFragment/general.test.ts @@ -0,0 +1,119 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ObservableStream, wait } from "@apollo/client/testing/internal"; + +test("can subscribe multiple times to watchFragment", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: { __typename: "Item", id: 1 }, + }); + + using stream1 = new ObservableStream(observable); + using stream2 = new ObservableStream(observable); + + await expect(stream1).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + await expect(stream2).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream1).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + await expect(stream2).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + await expect(stream1).not.toEmitAnything(); + await expect(stream2).not.toEmitAnything(); +}); + +test("dedupes watches when subscribing multiple times", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const ItemFragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment: ItemFragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment: ItemFragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(cache).toHaveNumWatches(0); + + const sub1 = observable.subscribe(() => {}); + const sub2 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + const sub3 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + [sub1, sub2, sub3].forEach((sub) => sub.unsubscribe()); + await wait(0); + expect(cache).toHaveNumWatches(0); + + const sub4 = observable.subscribe(() => {}); + expect(cache).toHaveNumWatches(1); + + sub4.unsubscribe(); + await wait(0); + expect(cache).toHaveNumWatches(0); +}); diff --git a/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts b/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts new file mode 100644 index 00000000000..00688a01eac --- /dev/null +++ b/src/core/__tests__/client.watchFragment/getCurrentResult.test.ts @@ -0,0 +1,751 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { + ObservableStream, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +interface Item { + __typename: "Item"; + id: number; + text: string; +} + +test("getCurrentResult returns initial result before subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns initial emitted value after subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns most recently emitted value", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + let lastResult = observable.getCurrentResult(); + expect(lastResult).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toBe(lastResult); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns most recently emitted value", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + let lastResult = observable.getCurrentResult(); + expect(lastResult).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toBe(lastResult); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns most recently emitted value", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + let lastResult = observable.getCurrentResult(); + expect(lastResult).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toBe(lastResult); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns updated value if changed before subscribing", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult returns referentially stable value when called multiple times", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + const lastResult = observable.getCurrentResult(); + expect(lastResult).toStrictEqualTyped({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + expect(observable.getCurrentResult()).toBe(lastResult); + expect(observable.getCurrentResult()).toBe(lastResult); + expect(observable.getCurrentResult()).toBe(lastResult); +}); + +test("getCurrentResult returns empty result with no cache data", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: {}, + dataState: "partial", + complete: false, + missing: "Dangling reference to missing Item:1 object", + }); +}); + +test("getCurrentResult is lazy computed", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + jest.spyOn(cache, "diff"); + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(cache.diff).not.toHaveBeenCalled(); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: {}, + dataState: "partial", + complete: false, + missing: "Dangling reference to missing Item:1 object", + }); + + expect(cache.diff).toHaveBeenCalledTimes(1); +}); + +test("getCurrentResult handles arrays", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult handles arrays with an active subscription", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + observable.subscribe(); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("getCurrentResult handles arrays with null", async () => { + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); +}); + +test("works with data masking", async () => { + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const parentObservable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const childObservable = client.watchFragment({ + fragment: detailsFragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(parentObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + expect(childObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(parentObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + expect(childObservable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); +}); + +test("works with data masking @unmask migrate mode", async () => { + using consoleSpy = spyOnConsole("warn"); + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + text: string; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment @unmask(mode: "migrate") + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + expect(console.warn).toHaveBeenCalledTimes(3); + for (let i = 0; i < 3; i++) { + expect(console.warn).toHaveBeenNthCalledWith( + i + 1, + expect.stringContaining("Accessing unmasked field on %s at path '%s'."), + "fragment 'ItemFragment'", + `[${i}].text` + ); + } + consoleSpy.warn.mockClear(); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + expect(observable.getCurrentResult()).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + expect(console.warn).toHaveBeenCalledTimes(3); + for (let i = 0; i < 3; i++) { + expect(console.warn).toHaveBeenNthCalledWith( + i + 1, + expect.stringContaining("Accessing unmasked field on %s at path '%s'."), + "fragment 'ItemFragment'", + `[${i}].text` + ); + } +}); diff --git a/src/core/__tests__/client.watchFragment/lists.test.ts b/src/core/__tests__/client.watchFragment/lists.test.ts new file mode 100644 index 00000000000..d1fa1677fc8 --- /dev/null +++ b/src/core/__tests__/client.watchFragment/lists.test.ts @@ -0,0 +1,822 @@ +import { waitFor } from "@testing-library/react"; + +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ObservableStream } from "@apollo/client/testing/internal"; + +test("can use list for `from` to get list of items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("allows mix of array identifiers", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", { __ref: "Item:3" }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 3, text: "Item #3" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns empty array with empty from", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ fragment, from: [] }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [], + dataState: "complete", + complete: true, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("returns result as partial when cache is empty", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:1 object", + 1: "Dangling reference to missing Item:2 object", + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as complete if all `from` items are null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, null], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as complete if all `from` items are complete or null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, { __typename: "Item", id: 5, text: "Item #5" }], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("returns as partial if some `from` items are incomplete mixed with null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observable = client.watchFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("can use static lists with useFragment with partially fulfilled items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { 2: "Dangling reference to missing Item:5 object" }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("updates items in the list with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("works with data masking", async () => { + type ItemDetails = { + __typename: string; + text: string; + } & { " $fragmentName"?: "ItemDetailsFragment" }; + + type Item = { + __typename: string; + id: number; + } & { + " $fragmentRefs"?: { ItemDetailsFragment: ItemDetails }; + }; + + const detailsFragment: TypedDocumentNode = gql` + fragment ItemDetailsFragment on Item { + text + } + `; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + ...ItemDetailsFragment + } + + ${detailsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const parentObservable = client.watchFragment({ + fragment, + fragmentName: "ItemFragment", + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const childObsrevable = client.watchFragment({ + fragment: detailsFragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + const parentStream = new ObservableStream(parentObservable); + const childStream = new ObservableStream(childObsrevable); + + await expect(parentStream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + dataState: "complete", + complete: true, + }); + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + await expect(parentStream).not.toEmitAnything(); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1 from batch" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item", text: "Item #1 from batch" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(parentStream).not.toEmitAnything(); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(childStream).toEmitTypedValue({ + data: [ + { __typename: "Item" }, + { __typename: "Item", text: "Item #2 updated" }, + { __typename: "Item", text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + await expect(parentStream).not.toEmitAnything(); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + fragmentName: "ItemFragment", + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(parentStream).not.toEmitAnything(); + await expect(childStream).not.toEmitAnything(); +}); + +test("can subscribe to the same object multiple times", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + }); + const stream = new ObservableStream(observable); + // ensure we only watch the item once + expect(cache).toHaveNumWatches(1); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 1, text: "Item #1" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: `Item #1 updated` }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 1 }, + ], + }); + expect(cache).toHaveNumWatches(1); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 1 }, + ], + }); + expect(cache).toHaveNumWatches(2); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 1, text: "Item #1 updated" }, + ], + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: `Item #1 updated again` }, + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 1, text: "Item #1 updated again" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [{ __typename: "Item", id: 1 }], + }); + + await expect(stream).toEmitTypedValue({ + data: [{ __typename: "Item", id: 1, text: "Item #1 updated again" }], + dataState: "complete", + complete: true, + }); + + // Ensure that removing one of the duplicates doesn't remove thw watch + // entirely + await waitFor(() => expect(cache).toHaveNumWatches(1)); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + await expect(stream).toEmitTypedValue({ + data: [{ __typename: "Item", id: 1, text: "Item #1" }], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchFragment/reobserve.test.ts b/src/core/__tests__/client.watchFragment/reobserve.test.ts new file mode 100644 index 00000000000..ad497fbad6f --- /dev/null +++ b/src/core/__tests__/client.watchFragment/reobserve.test.ts @@ -0,0 +1,342 @@ +import { waitFor } from "@testing-library/react"; + +import type { TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { ObservableStream } from "@apollo/client/testing/internal"; + +test("throws when changing `from` option from array to non-array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observableArray = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }); + + expect(() => + observableArray.reobserve({ + // @ts-expect-error + from: { __typename: "Item", id: 2 }, + }) + ).toThrow( + "Cannot change `from` option from array to non-array. Please provide `from` as an array." + ); +}); + +test("throws when changing `from` option from non-array to array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + const observableArray = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + + expect(() => + observableArray.reobserve({ + // @ts-expect-error + from: [{ __typename: "Item", id: 2 }], + }) + ).toThrow( + "Cannot change `from` option from non-array to array. Please provide `from` as an accepted non-array value." + ); +}); + +test("can change size of lists with reobserve", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ] as Array, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ from: [] }); + + await expect(stream).toEmitTypedValue({ + data: [], + dataState: "complete", + complete: true, + }); + + // watches are unsubscribed in a setTimeout. Wait for them all to + // unsubcribe to ensure the change is picked up when reobserved + await waitFor(() => expect(cache).toHaveNumWatches(0)); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 updated" }, + }); + + observable.reobserve({ + from: [{ __typename: "Item", id: 1 }], + }); + + await expect(stream).toEmitTypedValue({ + data: [{ __typename: "Item", id: 1, text: "Item #1 updated" }], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [{ __typename: "Item", id: 6 }], + }); + + await expect(stream).toEmitTypedValue({ + data: [null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:6 object", + }, + }); + + observable.reobserve({ + from: [null], + }); + + await expect(stream).toEmitTypedValue({ + data: [null], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("can reorder same array list", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: [ + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 1 }, + ], + }); + + await expect(stream).toEmitTypedValue({ + data: [ + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 1, text: "Item #1" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("can change observed non-array entity with reobserve", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + const observable = client.watchFragment({ + fragment, + from: { __typename: "Item", id: 1 }, + }); + const stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 1, text: "Item #1" }, + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: { __typename: "Item", id: 2 }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 2, text: "Item #2" }, + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: { __typename: "Item", id: 5 }, + }); + + await expect(stream).toEmitTypedValue({ + data: { __typename: "Item", id: 5, text: "Item #5" }, + dataState: "complete", + complete: true, + }); + + observable.reobserve({ + from: { __typename: "Item", id: 6 }, + }); + + await expect(stream).toEmitTypedValue({ + data: {}, + dataState: "partial", + complete: false, + missing: "Dangling reference to missing Item:6 object", + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index e1ba367c8fc..de92d4b0862 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -36,7 +36,10 @@ import { } from "@apollo/client"; import type { FragmentType } from "@apollo/client/masking"; import { ApolloProvider, useFragment, useQuery } from "@apollo/client/react"; -import { spyOnConsole } from "@apollo/client/testing/internal"; +import { + createClientWrapper, + spyOnConsole, +} from "@apollo/client/testing/internal"; import { MockedProvider } from "@apollo/client/testing/react"; import { concatPagination } from "@apollo/client/utilities"; import { removeDirectivesFromDocument } from "@apollo/client/utilities/internal"; @@ -1439,7 +1442,7 @@ describe("useFragment", () => { }); }); - it("returns correct data when options change", async () => { + it("returns correct data when from changes", async () => { const client = new ApolloClient({ cache: new InMemoryCache(), link: ApolloLink.empty(), @@ -1498,6 +1501,72 @@ describe("useFragment", () => { await expect(takeSnapshot).not.toRerender(); }); + it("returns correct data when options change", async () => { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + type User = { __typename: "User"; id: number; name: string }; + const fragment: TypedDocumentNode = gql` + fragment UserFragment on User { + id + name(casing: $casing) + } + `; + + client.writeFragment({ + fragment, + data: { __typename: "User", id: 1, name: "ALICE" }, + variables: { casing: "upper" }, + }); + + client.writeFragment({ + fragment, + data: { __typename: "User", id: 1, name: "alice" }, + variables: { casing: "lower" }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ casing }) => + useFragment({ + fragment, + from: { __typename: "User", id: 1 }, + variables: { casing }, + }), + { + initialProps: { casing: "upper" }, + wrapper: ({ children }) => ( + {children} + ), + } + ); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toStrictEqualTyped({ + complete: true, + data: { __typename: "User", id: 1, name: "ALICE" }, + dataState: "complete", + }); + } + + await rerender({ casing: "lower" }); + + { + const snapshot = await takeSnapshot(); + + expect(snapshot).toStrictEqualTyped({ + complete: true, + data: { __typename: "User", id: 1, name: "alice" }, + dataState: "complete", + }); + } + + await expect(takeSnapshot).not.toRerender(); + }); + it("does not rerender when fields with @nonreactive change", async () => { type Post = { __typename: "Post"; @@ -2551,6 +2620,582 @@ test("runs custom document transforms", async () => { await expect(takeSnapshot).not.toRerender(); }); +test("can use list for `from` to get list of items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns result as complete for null list item `from` value", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [null, null, null], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns as partial if some `from` items are incomplete mixed with null", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [null, null, { __typename: "Item", id: 5 }], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("allows mix of array identifiers", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", null], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "complete", + complete: true, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns empty array with empty from", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useFragment({ fragment, from: [] }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [], + dataState: "complete", + complete: true, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("returns incomplete results when cache is empty", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null, null, null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:1 object", + 1: "Dangling reference to missing Item:2 object", + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("can use static lists with useFragment with partially fulfilled items", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("handles changing list size", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, rerender } = await renderHookToSnapshotStream( + ({ from }) => useFragment({ fragment, from }), + { + initialProps: { + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }, + wrapper: createClientWrapper(client), + } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 5 }, + ], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [], + dataState: "complete", + complete: true, + }); + + await rerender({ + from: [{ __typename: "Item", id: 6 }], + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [null], + dataState: "partial", + complete: false, + missing: { + 0: "Dangling reference to missing Item:6 object", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("updates items in the list with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + null, + ], + dataState: "partial", + complete: false, + missing: { + 2: "Dangling reference to missing Item:5 object", + }, + }); + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "complete", + complete: true, + }); + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + dataState: "partial", + complete: false, + missing: { + 0: { + text: "Can't find field 'text' on Item:1 object", + }, + }, + }); + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + describe.skip("Type Tests", () => { test("NoInfer prevents adding arbitrary additional variables", () => { const typedNode = {} as TypedDocumentNode<{ foo: string }, { bar: number }>; @@ -2570,7 +3215,13 @@ describe.skip("Type Tests", () => { expectTypeOf< useFragment.Options >().branded.toEqualTypeOf<{ - from: string | StoreObject | Reference | FragmentType | null; + from: + | string + | StoreObject + | Reference + | FragmentType + | null + | Array | null>; fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; optimistic?: boolean; diff --git a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx index f910da745a4..b62da75bde3 100644 --- a/src/react/hooks/__tests__/useSuspenseFragment.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseFragment.test.tsx @@ -24,11 +24,7 @@ import { } from "@apollo/client"; import { ApolloProvider, useSuspenseFragment } from "@apollo/client/react"; import { MockSubscriptionLink } from "@apollo/client/testing"; -import { - renderAsync, - spyOnConsole, - wait, -} from "@apollo/client/testing/internal"; +import { renderAsync, spyOnConsole } from "@apollo/client/testing/internal"; import { MockedProvider } from "@apollo/client/testing/react"; import { removeDirectivesFromDocument } from "@apollo/client/utilities/internal"; import { InvariantError } from "@apollo/client/utilities/invariant"; @@ -1553,14 +1549,13 @@ test("tears down the subscription on unmount", async () => { expect(data).toEqual({ __typename: "Item", id: 1, text: "Item #1" }); } - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); unmount(); - // We need to wait a tick since the cleanup is run in a setTimeout to - // prevent strict mode bugs. - await wait(0); - expect(cache["watches"].size).toBe(0); + // Cleanup happens async so we just need to ensure it happens sometime after + // mount + await waitFor(() => expect(cache).toHaveNumWatches(0)); }); test("tears down all watches when rendering multiple records", async () => { @@ -1617,11 +1612,10 @@ test("tears down all watches when rendering multiple records", async () => { } unmount(); - // We need to wait a tick since the cleanup is run in a setTimeout to - // prevent strict mode bugs. - await wait(0); - expect(cache["watches"].size).toBe(0); + // Cleanup happens async so we just need to ensure it happens sometime after + // mount + await waitFor(() => expect(cache).toHaveNumWatches(0)); }); test("tears down watches after default autoDisposeTimeoutMs if component never renders again after suspending", async () => { @@ -1686,11 +1680,13 @@ test("tears down watches after default autoDisposeTimeoutMs if component never r // clear the microtask queue await act(() => Promise.resolve()); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.advanceTimersByTime(30_000); + // Run unsubscribe timeouts from cache watches + jest.runOnlyPendingTimers(); - expect(cache["watches"].size).toBe(0); + expect(cache).toHaveNumWatches(0); jest.useRealTimers(); }); @@ -1767,11 +1763,13 @@ test("tears down watches after configured autoDisposeTimeoutMs if component neve // clear the microtask queue await act(() => Promise.resolve()); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.advanceTimersByTime(5000); + // Run unsubscribe timeouts from cache watches + jest.runOnlyPendingTimers(); - expect(cache["watches"].size).toBe(0); + expect(cache).toHaveNumWatches(0); jest.useRealTimers(); }); @@ -1833,7 +1831,7 @@ test("cancels autoDisposeTimeoutMs if the component renders before timer finishe jest.advanceTimersByTime(30_000); - expect(cache["watches"].size).toBe(1); + expect(cache).toHaveNumWatches(1); jest.useRealTimers(); }); @@ -1992,6 +1990,75 @@ describe.skip("type tests", () => { } }); + test("returns null[] when `from` is null[]", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ fragment, from: [null] }); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [null], + }); + + expectTypeOf(data).branded.toEqualTypeOf>(); + } + }); + + test("returns Array when `from` includes null with non-null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: [null, { __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [null, { __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).branded.toEqualTypeOf>(); + } + }); + + test("returns TData[] when `from` includes array of non-null", () => { + type Data = { foo: string }; + type Vars = Record; + const fragment: TypedDocumentNode = gql``; + + { + const { data } = useSuspenseFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).toEqualTypeOf>(); + } + + { + const { data } = useSuspenseFragment({ + fragment: gql``, + from: [{ __typename: "Item", id: 1 }], + }); + + expectTypeOf(data).branded.toEqualTypeOf>(); + } + }); + test("variables are optional and can be anything with an untyped DocumentNode", () => { const fragment = gql``; diff --git a/src/react/hooks/__tests__/useSuspenseFragment/lists.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment/lists.test.tsx new file mode 100644 index 00000000000..b6c99dcbe64 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseFragment/lists.test.tsx @@ -0,0 +1,1084 @@ +import type { RenderOptions } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import { userEvent } from "@testing-library/user-event"; +import React, { Suspense } from "react"; + +import type { StoreObject, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, InMemoryCache } from "@apollo/client"; +import { useSuspenseFragment } from "@apollo/client/react"; +import { createClientWrapper } from "@apollo/client/testing/internal"; + +async function renderUseSuspenseFragment( + renderHook: (props: Props) => useSuspenseFragment.Result, + options: Pick & { initialProps?: Props } +) { + function UseSuspenseFragment({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useSuspenseFragment" }); + replaceSnapshot(renderHook(props as any)); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function App({ props }: { props: Props | undefined }) { + return ( + }> + + + ); + } + + const { render, takeRender, replaceSnapshot } = createRenderStream< + useSuspenseFragment.Result + >({ skipNonTrackingRenders: true }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + return { takeRender, rerender }; +} + +test("renders list and does not suspend list for `from` array when written to cache", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("updates items in the list with cache writes", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 2, + text: "Item #2 updated", + }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + client.cache.batch({ + update: (cache) => { + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 1, + text: "Item #1 from batch", + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 5, + text: "Item #5 from batch", + }, + }); + }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 from batch" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + { __typename: "Item", id: 5, text: "Item #5 from batch" }, + ], + }); + } + + // should not cause rerender since its an item not watched + client.writeFragment({ + fragment, + data: { + __typename: "Item", + id: 6, + text: "Item #6 ignored", + }, + }); + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns null array for null `from` array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => useSuspenseFragment({ fragment, from: [null, null, null] }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [null, null, null], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("handles mixed array of identifiers in `from`", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [{ __typename: "Item", id: 1 }, "Item:2", null], + }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + null, + ], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("does not suspend and returns empty array for empty `from` array", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => useSuspenseFragment({ fragment, from: [] }), + { wrapper: createClientWrapper(client) } + ); + + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [], + }); + + await expect(takeRender).not.toRerender(); +}); + +test("suspends until all items are complete", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + + await expect(takeRender).not.toRerender({ timeout: 20 }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + await expect(takeRender).not.toRerender({ timeout: 20 }); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends until all items are complete with partially complete results on initial render", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 2; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("suspends when an item changes from complete to partial", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const { cache } = client; + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseFragment( + () => + useSuspenseFragment({ + fragment, + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + cache.modify({ + id: cache.identify({ __typename: "Item", id: 1 }), + fields: { + text: (_, { DELETE }) => DELETE, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1 is back" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1 is back" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("handles changing list size", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + + for (let i = 1; i <= 5; i++) { + client.writeFragment({ + fragment, + data: { __typename: "Item", id: i, text: `Item #${i}` }, + }); + } + + using _disabledAct = disableActEnvironment(); + const { takeRender, rerender } = await renderUseSuspenseFragment( + ({ from }) => useSuspenseFragment({ fragment, from }), + { + initialProps: { + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ], + }, + wrapper: createClientWrapper(client), + } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + } + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + { __typename: "Item", id: 5 }, + ], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await rerender({ + from: [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 5 }, + ], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + } + + await rerender({ + from: [], + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [], + }); + } + + await rerender({ + from: [{ __typename: "Item", id: 6 }], + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 6, text: "Item #6" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [{ __typename: "Item", id: 6, text: "Item #6" }], + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("rendering same items in multiple useSuspenseFragment hooks allows for rerendering a different list in the other", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: ApolloLink.empty(), + }); + + function UseSuspenseFragment({ + id, + items, + }: { + id: number; + items: StoreObject[]; + }) { + useTrackRenders({ name: `useSuspenseFragment ${id}` }); + mergeSnapshot({ + [`items${id}`]: useSuspenseFragment({ fragment, from: items }), + }); + + return null; + } + + function SuspenseFallback({ id }: { id: number }) { + // Reset snapshot so it doesn't seem like the useSuspenseFragment hook + // rendered + mergeSnapshot({ [`items${id}`]: undefined }); + useTrackRenders({ name: `SuspenseFallback ${id}` }); + + return null; + } + + function App({ + items1, + items2, + }: { + items1: StoreObject[]; + items2: StoreObject[]; + }) { + return ( + <> + }> + + + }> + + + + ); + } + + using _disabledAct = disableActEnvironment(); + const { render, takeRender, mergeSnapshot } = createRenderStream<{ + items1: useSuspenseFragment.Result | undefined; + items2: useSuspenseFragment.Result | undefined; + }>({ + skipNonTrackingRenders: true, + initialSnapshot: { items1: undefined, items2: undefined }, + }); + + const initialItems = [ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ]; + + const { rerender } = await render( + , + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "SuspenseFallback 2", + "SuspenseFallback 1", + ]); + expect(snapshot).toStrictEqualTyped({ + items1: undefined, + items2: undefined, + }); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + }); + } + // We expect 4 watchers instead of 2 because we want each hook to have its own + // `FragmentReference`, otherwise `reobserve` will affect both hooks. More + // than 4 indicates we are recreating fragment refs more than necessary + await waitFor(() => expect(cache).toHaveNumWatches(4)); + + await rerender( + + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "SuspenseFallback 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: undefined, + }); + } + await waitFor(() => expect(cache).toHaveNumWatches(5)); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment 2"]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }, + }); + } + + await rerender( + + ); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }, + items2: { + data: [{ __typename: "Item", id: 2, text: "Item #2" }], + }, + }); + } + await waitFor(() => expect(cache).toHaveNumWatches(3)); + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2 updated" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useSuspenseFragment 2", + "useSuspenseFragment 1", + ]); + expect(snapshot).toStrictEqual({ + items1: { + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2 updated" }, + ], + }, + items2: { + data: [{ __typename: "Item", id: 2, text: "Item #2 updated" }], + }, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("works with transitions", async () => { + type Item = { + __typename: string; + id: number; + text?: string; + }; + + const fragment: TypedDocumentNode = gql` + fragment ItemFragment on Item { + id + text + } + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.empty(), + }); + const user = userEvent.setup(); + + function UseSuspenseFragment({ items }: { items: StoreObject[] }) { + useTrackRenders({ name: "useSuspenseFragment" }); + replaceSnapshot(useSuspenseFragment({ fragment, from: items })); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "SuspenseFallback" }); + + return null; + } + + function App() { + const [items, setItems] = React.useState([ + { __typename: "Item", id: 1 }, + { __typename: "Item", id: 2 }, + ]); + const [isPending, startTransition] = React.useTransition(); + + return ( + <> + + }> + + + + ); + } + + using _disabledAct = disableActEnvironment(); + const { render, takeRender, replaceSnapshot } = createRenderStream< + useSuspenseFragment.Result + >({ skipNonTrackingRenders: true }); + + await render(, { wrapper: createClientWrapper(client) }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqualTyped(["SuspenseFallback"]); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 1, text: "Item #1" }, + }); + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 2, text: "Item #2" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqualTyped(["useSuspenseFragment"]); + expect(snapshot).toStrictEqualTyped({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + } + + const button = screen.getByText("Change items"); + await user.click(button); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqual({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + ], + }); + expect(button).toBeDisabled(); + } + + client.writeFragment({ + fragment, + data: { __typename: "Item", id: 5, text: "Item #5" }, + }); + + { + const { renderedComponents, snapshot } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseFragment"]); + expect(snapshot).toStrictEqual({ + data: [ + { __typename: "Item", id: 1, text: "Item #1" }, + { __typename: "Item", id: 2, text: "Item #2" }, + { __typename: "Item", id: 5, text: "Item #5" }, + ], + }); + expect(button).not.toBeDisabled(); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/useFragment.ts b/src/react/hooks/useFragment.ts index 94daa7c2799..df72bf8f821 100644 --- a/src/react/hooks/useFragment.ts +++ b/src/react/hooks/useFragment.ts @@ -1,4 +1,3 @@ -import { equal } from "@wry/equality"; import * as React from "react"; import type { @@ -9,12 +8,7 @@ import type { OperationVariables, TypedDocumentNode, } from "@apollo/client"; -import type { - Cache, - MissingTree, - Reference, - StoreObject, -} from "@apollo/client/cache"; +import type { MissingTree, Reference, StoreObject } from "@apollo/client/cache"; import type { FragmentType, MaybeMasked } from "@apollo/client/masking"; import type { NoInfer } from "@apollo/client/utilities/internal"; @@ -22,8 +16,18 @@ import { useDeepMemo, wrapHook } from "./internal/index.js"; import { useApolloClient } from "./useApolloClient.js"; import { useSyncExternalStore } from "./useSyncExternalStore.js"; +type FromPrimitive = + | StoreObject + | Reference + | FragmentType> + | string + | null; + +type From = FromPrimitive | Array>; + export declare namespace useFragment { import _self = useFragment; + export interface Options { /** * A GraphQL document created using the `gql` template string tag from @@ -48,12 +52,7 @@ export declare namespace useFragment { /** * An object containing a `__typename` and primary key fields (such as `id`) identifying the entity object from which the fragment will be retrieved, or a `{ __ref: "..." }` reference, or a `string` ID (uncommon). */ - from: - | StoreObject - | Reference - | FragmentType> - | string - | null; + from: From; /** * Whether to read from optimistic or non-optimistic cache data. If @@ -93,12 +92,18 @@ export declare namespace useFragment { /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#missing:member} */ missing?: never; } & GetDataState, "complete">) - | ({ + | { /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#complete:member} */ complete: false; /** {@inheritDoc @apollo/client/react!useFragment.DocumentationTypes.useFragment.Result#missing:member} */ missing?: MissingTree; - } & GetDataState, "partial">); + /** {@inheritDoc @apollo/client!QueryResultDocumentation#data:member} */ + data: TData extends Array ? + Array> + : DataValue.Partial; + /** {@inheritDoc @apollo/client!QueryResultDocumentation#dataState:member} */ + dataState: "partial"; + }; export namespace DocumentationTypes { namespace useFragment { @@ -138,7 +143,44 @@ export declare namespace useFragment { export function useFragment< TData = unknown, TVariables extends OperationVariables = OperationVariables, ->(options: useFragment.Options): useFragment.Result { +>( + options: useFragment.Options & { + from: Array>>; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options & { + from: Array; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options & { + from: Array>; + } +): useFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useFragment:function(1)} */ +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(options: useFragment.Options): useFragment.Result; + +export function useFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useFragment.Options +): useFragment.Result | useFragment.Result> { "use no memo"; return wrapHook( "useFragment", @@ -150,115 +192,90 @@ export function useFragment< function useFragment_( options: useFragment.Options -): useFragment.Result { +): useFragment.Result | useFragment.Result> { const client = useApolloClient(options.client); - const { cache } = client; const { from, ...rest } = options; + const { cache } = client; - // We calculate the cache id seperately from `stableOptions` because we don't - // want changes to non key fields in the `from` property to affect - // `stableOptions` and retrigger our subscription. If the cache identifier - // stays the same between renders, we want to reuse the existing subscription. - const id = React.useMemo( - () => - typeof from === "string" ? from - : from === null ? null - : cache.identify(from), - [cache, from] - ); + // We calculate the cache id seperately because we don't want changes to non + // key fields in the `from` property to recreate the observable. If the cache + // identifier stays the same between renders, we want to reuse the existing + // subscription. + const ids = useDeepMemo(() => { + const fromArray = Array.isArray(from) ? from : [from]; - const stableOptions = useDeepMemo(() => ({ ...rest, from: id! }), [rest, id]); + const ids = fromArray.map((value) => + typeof value === "string" ? value + : value === null ? null + : cache.identify(value) + ); - // Since .next is async, we need to make sure that we - // get the correct diff on the next render given new diffOptions - const diff = React.useMemo(() => { - const { fragment, fragmentName, from, optimistic = true } = stableOptions; + return Array.isArray(from) ? ids : ids[0]; + }, [cache, from]); - if (from === null) { - return { - result: diffToResult({ - result: {}, - complete: false, - } as Cache.DiffResult), - }; - } + const stableOptions = useDeepMemo(() => rest, [rest]); - const { cache } = client; - const diff = cache.diff({ - ...stableOptions, - returnPartialData: true, - id: from, - query: cache["getFragmentDoc"]( - client["transform"](fragment), - fragmentName - ), - optimistic, - }); + const [previous, setPrevious] = React.useReducer( + (state, newState) => ({ ...state, ...newState }), + { client, ids, stableOptions } + ); + const [observable, setObservable] = React.useState(() => + client.watchFragment({ ...rest, from: ids as any }) + ); + + if (client !== previous.client || stableOptions !== previous.stableOptions) { + setPrevious({ client, stableOptions, ids }); + setObservable(client.watchFragment({ ...stableOptions, from: ids as any })); + } + + if (ids !== previous.ids) { + setPrevious({ ids }); + observable.reobserve({ from: ids as any }); + } - return { - result: diffToResult({ - ...diff, - result: client["queryManager"].maskFragment({ - fragment, - fragmentName, - // TODO: Revert to `diff.result` once `useFragment` supports `null` as - // valid return value - data: diff.result === null ? {} : diff.result, - }) as any, - }), - }; - }, [client, stableOptions]); + const currentResultRef = + React.useRef>(undefined); - // Used for both getSnapshot and getServerSnapshot - const getSnapshot = React.useCallback(() => diff.result, [diff]); + const getSnapshot = React.useCallback(() => { + const result = observable.getCurrentResult(); + currentResultRef.current = result; + return result; + }, [observable]); return useSyncExternalStore( React.useCallback( - (forceUpdate) => { + (update) => { let lastTimeout = 0; + const subscription = observable.subscribe({ + next: (result) => { + // If we get another update before we've re-rendered, bail out of + // the update and try again. This ensures that the relative timing + // between useQuery and useFragment stays roughly the same as + // fixed in https://github.com/apollographql/apollo-client/pull/11083 + clearTimeout(lastTimeout); + lastTimeout = setTimeout(() => { + // After the initial mount, React will always rerender the + // component when calling update() even if getSnapshot() doesn't + // change. We want to avoid rerendering the component if + // getSnapshot has already rendered this value. + // + // This can happen when rerendering with new IDs when reobserve is + // called since the value is synchronously updated during render. + if (currentResultRef.current !== result) { + update(); + } + }) as any; + }, + }); - const subscription = - stableOptions.from === null ? - null - : client.watchFragment(stableOptions).subscribe({ - next: (result) => { - // Avoid unnecessarily rerendering this hook for the initial result - // emitted from watchFragment which should be equal to - // `diff.result`. - if (equal(result, diff.result)) return; - diff.result = result; - // If we get another update before we've re-rendered, bail out of - // the update and try again. This ensures that the relative timing - // between useQuery and useFragment stays roughly the same as - // fixed in https://github.com/apollographql/apollo-client/pull/11083 - clearTimeout(lastTimeout); - lastTimeout = setTimeout(forceUpdate) as any; - }, - }); return () => { - subscription?.unsubscribe(); + subscription.unsubscribe(); clearTimeout(lastTimeout); }; }, - [client, stableOptions, diff] + [observable] ), getSnapshot, getSnapshot ); } - -function diffToResult( - diff: Cache.DiffResult -): useFragment.Result { - const result = { - data: diff.result, - complete: !!diff.complete, - dataState: diff.complete ? "complete" : "partial", - } as useFragment.Result; // TODO: Remove assertion once useFragment returns null - - if (diff.missing) { - result.missing = diff.missing.missing; - } - - return result; -} diff --git a/src/react/hooks/useSuspenseFragment.ts b/src/react/hooks/useSuspenseFragment.ts index de6e0950858..e69218bdeea 100644 --- a/src/react/hooks/useSuspenseFragment.ts +++ b/src/react/hooks/useSuspenseFragment.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { + ApolloCache, ApolloClient, DataValue, DocumentNode, @@ -20,16 +21,18 @@ import type { } from "@apollo/client/utilities/internal"; import { __use } from "./internal/__use.js"; -import { wrapHook } from "./internal/index.js"; +import { useDeepMemo, wrapHook } from "./internal/index.js"; import { useApolloClient } from "./useApolloClient.js"; -type From = +type FromPrimitive = | StoreObject | Reference | FragmentType> | string | null; +type From = FromPrimitive | Array>; + export declare namespace useSuspenseFragment { import _self = useSuspenseFragment; export namespace Base { @@ -103,6 +106,36 @@ const NULL_PLACEHOLDER = [] as unknown as [ ]; /** #TODO documentation */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array>>; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ +export function useSuspenseFragment< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>( + options: useSuspenseFragment.Options & { + from: Array>; + } +): useSuspenseFragment.Result>; + +/** {@inheritDoc @apollo/client/react!useSuspenseFragment:function(1)} */ export function useSuspenseFragment< TData, TVariables extends OperationVariables = OperationVariables, @@ -165,23 +198,46 @@ function useSuspenseFragment_< const { from, variables } = options; const { cache } = client; - const id = React.useMemo( - () => - typeof from === "string" ? from - : from === null ? null - : cache.identify(from), - [cache, from] - ) as string | null; + const ids = useDeepMemo(() => { + return Array.isArray(from) ? + from.map((id) => toStringId(cache, id)) + : toStringId(cache, from); + }, [cache, from]); + + // Keep the first set of ids that can be stored after the initial + // non-suspended mount to use for the suspense cache key. This ensures we keep + // around the same `FragmentReference` even as the `from` option changes so + // that we can take advantage of `reobserve` to maintain existing watches as + // much as possible. If we used the `ids` in the cache key (which might change + // between renders), we'd get a new `client.watchFragment` observable + // which would tear down existing watches, even for items that remain the same + // between the changed items in the `from` array. + let [stableIds, setStableIds] = React.useState(() => ids); + const [previousIds, setPreviousIds] = React.useState(ids); + + if (stableIds === null && ids !== null) { + stableIds = ids; + setStableIds(ids); + } const fragmentRef = - id === null ? null : ( + ids === null ? null : ( getSuspenseCache(client).getFragmentRef( - [id, options.fragment, canonicalStringify(variables)], + [ + stableIds as string | Array, + options.fragment, + canonicalStringify(variables), + ], client, - { ...options, variables: variables as TVariables, from: id } + { ...options, variables: variables as TVariables, from: ids } ) ); + if (ids !== previousIds) { + setPreviousIds(ids); + fragmentRef?.reobserve({ from: ids as any }); + } + let [current, setPromise] = React.useState< [FragmentKey, Promise | null>] >( @@ -220,3 +276,10 @@ function useSuspenseFragment_< return { data }; } + +function toStringId(cache: ApolloCache, from: FromPrimitive) { + return ( + typeof from === "string" ? from + : from === null ? null + : cache.identify(from)) as string | null; +} diff --git a/src/react/internal/cache/FragmentReference.ts b/src/react/internal/cache/FragmentReference.ts index f8d10e5bff5..aa9a5aead2c 100644 --- a/src/react/internal/cache/FragmentReference.ts +++ b/src/react/internal/cache/FragmentReference.ts @@ -1,7 +1,11 @@ import { equal } from "@wry/equality"; -import type { Observable, Subscription } from "rxjs"; +import type { Subscription } from "rxjs"; -import type { ApolloClient, OperationVariables } from "@apollo/client"; +import type { + ApolloCache, + ApolloClient, + OperationVariables, +} from "@apollo/client"; import type { MaybeMasked } from "@apollo/client/masking"; import type { DecoratedPromise } from "@apollo/client/utilities/internal"; import { @@ -23,9 +27,7 @@ export class FragmentReference< TData = unknown, TVariables extends OperationVariables = OperationVariables, > { - public readonly observable: Observable< - ApolloClient.WatchFragmentResult - >; + public readonly observable: ApolloClient.ObservableFragment; public readonly key: FragmentKey = {}; public promise!: FragmentRefPromise>; @@ -44,7 +46,7 @@ export class FragmentReference< TData, TVariables > & { - from: string; + from: string | Array; }, options: FragmentReferenceOptions ) { @@ -58,7 +60,7 @@ export class FragmentReference< this.onDispose = options.onDispose; } - const diff = this.getDiff(client, watchFragmentOptions); + const result = this.observable.getCurrentResult(); // Start a timer that will automatically dispose of the query if the // suspended resource does not use this fragmentRef in the given time. This @@ -74,8 +76,8 @@ export class FragmentReference< }; this.promise = - diff.complete ? - createFulfilledPromise(diff.result) + result.complete ? + createFulfilledPromise(result.data) : this.createPendingPromise(); this.subscribeToFragment(); @@ -111,6 +113,10 @@ export class FragmentReference< }; } + reobserve(options: ApolloCache.WatchFragmentReobserveOptions) { + this.observable.reobserve(options); + } + private dispose() { this.subscription.unsubscribe(); } @@ -175,34 +181,4 @@ export class FragmentReference< }) ); } - - private getDiff( - client: ApolloClient, - options: ApolloClient.WatchFragmentOptions & { - from: string; - } - ) { - const { cache } = client; - const { from, fragment, fragmentName } = options; - - const diff = cache.diff({ - ...options, - query: cache["getFragmentDoc"]( - client["transform"](fragment), - fragmentName - ), - returnPartialData: true, - id: from, - optimistic: true, - }); - - return { - ...diff, - result: client["queryManager"].maskFragment({ - fragment, - fragmentName, - data: diff.result, - }) as MaybeMasked, - }; - } } diff --git a/src/react/internal/cache/SuspenseCache.ts b/src/react/internal/cache/SuspenseCache.ts index b42aa22cfb7..98597e2c3e3 100644 --- a/src/react/internal/cache/SuspenseCache.ts +++ b/src/react/internal/cache/SuspenseCache.ts @@ -60,7 +60,7 @@ export class SuspenseCache { cacheKey: FragmentCacheKey, client: ApolloClient, options: ApolloClient.WatchFragmentOptions & { - from: string; + from: string | Array; } ) { const ref = this.fragmentRefs.lookupArray(cacheKey) as { diff --git a/src/react/internal/cache/types.ts b/src/react/internal/cache/types.ts index a163431ad9d..e681d2b45f6 100644 --- a/src/react/internal/cache/types.ts +++ b/src/react/internal/cache/types.ts @@ -7,7 +7,7 @@ export type CacheKey = [ ]; export type FragmentCacheKey = [ - cacheId: string, + cacheId: string | Array, fragment: DocumentNode, stringifiedVariables: string, ]; diff --git a/src/testing/matchers/index.d.ts b/src/testing/matchers/index.d.ts index e0b73018afd..b8509160b58 100644 --- a/src/testing/matchers/index.d.ts +++ b/src/testing/matchers/index.d.ts @@ -8,6 +8,7 @@ import type { MatcherHintOptions } from "jest-matcher-utils"; import type { ApolloClient, DocumentNode, + InMemoryCache, ObservableQuery, OperationVariables, } from "@apollo/client"; @@ -56,6 +57,9 @@ interface ApolloCustomMatchers { */ toMatchDocument(document: DocumentNode): R; + toHaveNumWatches: T extends InMemoryCache ? (size: number) => R + : { error: "matcher needs to be called on an InMemoryCache instance" }; + /** * Used to determine if the Suspense cache has a cache entry. */ diff --git a/src/testing/matchers/index.ts b/src/testing/matchers/index.ts index 0ba1ebc38b2..1787bed763e 100644 --- a/src/testing/matchers/index.ts +++ b/src/testing/matchers/index.ts @@ -8,6 +8,7 @@ import { toEmitAnything } from "./toEmitAnything.js"; import { toEmitError } from "./toEmitError.js"; import { toEmitNext } from "./toEmitNext.js"; import { toEmitTypedValue } from "./toEmitTypedValue.js"; +import { toHaveNumWatches } from "./toHaveNumWatches.js"; import { toHaveSuspenseCacheEntryUsing } from "./toHaveSuspenseCacheEntryUsing.js"; import { toMatchDocument } from "./toMatchDocument.js"; import { @@ -24,6 +25,7 @@ expect.extend({ toEmitNext, toEmitTypedValue, toBeDisposed, + toHaveNumWatches, toHaveSuspenseCacheEntryUsing, toMatchDocument, toBeGarbageCollected, diff --git a/src/testing/matchers/toHaveNumWatches.ts b/src/testing/matchers/toHaveNumWatches.ts new file mode 100644 index 00000000000..fd502f9e21c --- /dev/null +++ b/src/testing/matchers/toHaveNumWatches.ts @@ -0,0 +1,35 @@ +import type { MatcherFunction } from "expect"; + +import type { InMemoryCache } from "@apollo/client"; + +export const toHaveNumWatches: MatcherFunction<[size: number]> = function ( + _cache, + size +) { + const hint = this.utils.matcherHint("toHaveNumWatches", "cache", "size", { + isNot: this.isNot, + }); + const cache = _cache as InMemoryCache; + const watchSize = cache["watches"].size; + const watchIds = Array.from(cache["watches"].values()).map( + (watch) => `'${watch.id ?? "ROOT_QUERY"}'` + ); + const pass = watchSize === size; + + const plural = (size: number) => (size === 1 ? "watch" : "watches"); + + return { + pass, + message: () => { + return `${hint}\n\nExpected cache ${ + this.isNot ? "not " : "" + }to have ${this.utils.printExpected(size)} ${plural( + size + )} but instead it had ${this.utils.printReceived(watchSize)} ${plural( + watchSize + )}.\n\nWatches: ${this.utils.printReceived( + "[" + watchIds.join(", ") + "]" + )}`; + }, + }; +}; diff --git a/src/utilities/DeepPartial.ts b/src/utilities/DeepPartial.ts index 0ed92959eea..7fcb8d87b7e 100644 --- a/src/utilities/DeepPartial.ts +++ b/src/utilities/DeepPartial.ts @@ -37,8 +37,8 @@ export type DeepPartial = T // Test for non-tuples ) ? readonly TItem[] extends T ? - ReadonlyArray> - : Array> + ReadonlyArray> + : Array> : DeepPartialObject : DeepPartialObject : unknown; diff --git a/src/utilities/internal/combineLatestBatched.ts b/src/utilities/internal/combineLatestBatched.ts new file mode 100644 index 00000000000..0deb929fb39 --- /dev/null +++ b/src/utilities/internal/combineLatestBatched.ts @@ -0,0 +1,71 @@ +import { EMPTY, Observable } from "rxjs"; + +/** + * Like `combineLatest` but with some differences: + * + * - It only works on arrays as an input + * - Batches updates to each array index that contains a referentially equal + * observable + * - Doesn't allow for custom scheduler + * - Expects array of constructed observables instead of `Array` + */ +export function combineLatestBatched(observables: Array>) { + if (observables.length === 0) { + return EMPTY; + } + + return new Observable>((observer) => { + const { length } = observables; + // Keeps track of current values for each observable + const values: T[] = Array.from({ length }); + // Track the number of active subscriptions so we know when to complete this + // observable + let active = length; + // Track how many observables are left to emit their first value + let remainingFirstValues = length; + + // Used to batch an update each item in the array that share an observable + // so that they can be emitted together. + const indexesByObservable = new Map, Set>(); + + observables.forEach((source, idx) => { + if (!indexesByObservable.has(source)) { + indexesByObservable.set(source, new Set()); + } + + indexesByObservable.get(source)!.add(idx); + }); + + // Subscribe to each unique observable instead of the raw source array of + // observables since we want at most 1-subscription per unique observable. + // This ensures an update can write to multiple indexes before emitting the + // result. + indexesByObservable.forEach((indexes, source) => { + let hasFirstValue = false; + const subscription = source.subscribe({ + next: (value) => { + indexes.forEach((idx) => (values[idx] = value)); + + if (!hasFirstValue) { + hasFirstValue = true; + remainingFirstValues -= indexes.size; + } + + if (!remainingFirstValues) { + observer.next(values.slice()); + } + }, + complete: () => { + active -= indexes.size; + + if (!active) { + observer.complete(); + } + }, + error: observer.error.bind(observer), + }); + + observer.add(subscription); + }); + }); +} diff --git a/src/utilities/internal/index.ts b/src/utilities/internal/index.ts index 47add2520ec..e551ad4fb0d 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -17,6 +17,7 @@ export { argumentsObjectFromField } from "./argumentsObjectFromField.js"; export { canUseDOM } from "./canUseDOM.js"; export { checkDocument } from "./checkDocument.js"; export { cloneDeep } from "./cloneDeep.js"; +export { combineLatestBatched } from "./combineLatestBatched.js"; export { compact } from "./compact.js"; export { createFragmentMap } from "./createFragmentMap.js"; export { createFulfilledPromise } from "./createFulfilledPromise.js";