diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index ca4bd747f58..860d25d2985 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -20,15 +20,18 @@ import type { FragmentType } from '@apollo/client/masking'; import { getApolloCacheMemoryInternals } from '@apollo/client/utilities/internal'; import type { GetDataState } from '@apollo/client'; import { getInMemoryCacheMemoryInternals } from '@apollo/client/utilities/internal'; +import type { GraphQLScalarType } from 'graphql'; import type { Incremental } from '@apollo/client/incremental'; import type { InlineFragmentNode } from 'graphql'; import type { IsAny } from '@apollo/client/utilities/internal'; +import type { IsLooselyEqual } from '@apollo/client/utilities/internal'; import { isReference } from '@apollo/client/utilities'; import type { NoInfer as NoInfer_2 } from '@apollo/client/utilities/internal'; import { Observable } from 'rxjs'; import type { OperationVariables } from '@apollo/client'; import type { Prettify } from '@apollo/client/utilities/internal'; import { Reference } from '@apollo/client/utilities'; +import type { RemoveIndexSignature } from '@apollo/client/utilities/internal'; import type { SelectionSetNode } from 'graphql'; import type { StoreObject } from '@apollo/client/utilities'; import type { StoreValue } from '@apollo/client/utilities'; @@ -46,9 +49,17 @@ type AllFieldsModifier> = Modifier = StoreObject | Reference | FragmentType> | string; // (undocumented) + export type GetScalarType = ApolloCache.Scalars[TKey] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + // (undocumented) export interface ObservableFragment extends Observable> { getCurrentResult: () => ApolloCache.WatchFragmentResult; } + // (undocumented) + export interface Scalars { + } export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; @@ -92,6 +103,8 @@ export abstract class ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) + getScalar(key: TKey): ApolloCache.GetScalarType | undefined; + // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) lookupFragment(fragmentName: string): FragmentDefinitionNode | null; @@ -117,6 +130,8 @@ export abstract class ApolloCache { abstract reset(options?: Cache_2.ResetOptions): Promise; resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2): TVariables; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2 | undefined): TVariables | undefined; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; // (undocumented) @@ -488,6 +503,7 @@ export type FieldPolicy; merge?: FieldMergeFunction | boolean; + scalar?: ScalarNames; }; // @public (undocumented) @@ -553,9 +569,32 @@ export interface IgnoreModifier { // @public (undocumented) const _ignoreModifier: unique symbol; +// @public (undocumented) +export namespace InMemoryCache { + // Warning: (ae-forgotten-export) The symbol "KnownScalars" needs to be exported by the entry point index.d.ts + // + // (undocumented) + export type ScalarsOption = { + [ScalarName in keyof KnownScalars as IsLooselyEqual extends true ? ScalarName : never]?: KnownScalars[ScalarName] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + } & { + [ScalarName in keyof KnownScalars as IsLooselyEqual extends true ? never : ScalarName]: KnownScalars[ScalarName] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + } & (ApolloCache.Scalars extends (Record) ? Record> : {}); +} + // @public (undocumented) export class InMemoryCache extends ApolloCache { - constructor(config?: InMemoryCacheConfig); + constructor(...args: {} extends InMemoryCache.ScalarsOption ? [ + config?: InMemoryCacheConfig + ] : [config: InMemoryCacheConfig]); // (undocumented) readonly assumeImmutableResults = true; batch(options: Cache_2.BatchOptions): TUpdateResult; @@ -580,6 +619,8 @@ export class InMemoryCache extends ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getInMemoryCacheMemoryInternals; // (undocumented) + getScalar(key: TKey): ApolloCache.GetScalarType extends (Scalar) ? IsLooselyEqual extends true ? ApolloCache.GetScalarType | undefined : ApolloCache.GetScalarType : never; + // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) lookupFragment(fragmentName: string): FragmentDefinitionNode | null; @@ -609,6 +650,8 @@ export class InMemoryCache extends ApolloCache { restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2): TVariables; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2 | undefined): TVariables | undefined; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; // (undocumented) @@ -618,15 +661,30 @@ export class InMemoryCache extends ApolloCache { } // @public (undocumented) -export interface InMemoryCacheConfig extends ApolloReducerConfig { - // (undocumented) - fragments?: FragmentRegistryAPI; - // (undocumented) +export type InMemoryCacheConfig = ApolloReducerConfig & { + resultCaching?: boolean; possibleTypes?: PossibleTypesMap; + typePolicies?: TypePolicies; + fragments?: FragmentRegistryAPI; + inputObjects?: InputObjectsOption; +} & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { + scalars?: Record; +} : { + scalars?: InMemoryCache.ScalarsOption; +} : { + scalars: InMemoryCache.ScalarsOption; +}); + +// @public (undocumented) +export interface InputObjectConfig { // (undocumented) - resultCaching?: boolean; + fields: Record; +} + +// @public (undocumented) +export interface InputObjectsOption { // (undocumented) - typePolicies?: TypePolicies; + [inputObjectName: string]: InputObjectConfig; } // @public (undocumented) @@ -664,6 +722,9 @@ type KeyFieldsFunction = (object: Readonly, context: KeyFieldsConte // @public (undocumented) type KeySpecifier = ReadonlyArray; +// @public (undocumented) +type KnownScalars = RemoveIndexSignature; + // @public (undocumented) class Layer extends EntityStore { constructor(id: string, parent: EntityStore, replay: (layer: EntityStore) => any, group: CacheGroup); @@ -827,6 +888,8 @@ export class Policies { getMergeFunction(parentTypename: string | undefined, fieldName: string, childTypename: string | undefined): FieldMergeFunction | undefined; // (undocumented) getReadFunction(typename: string | undefined, fieldName: string): FieldReadFunction | undefined; + // (undocumented) + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // Warning: (ae-forgotten-export) The symbol "FieldSpecifier" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -931,6 +994,39 @@ class Root extends EntityStore { // @public (undocumented) type SafeReadonly = T extends object ? Readonly : T; +// @public (undocumented) +export namespace Scalar { + // (undocumented) + export interface Options { + // (undocumented) + is?(value: TSerialized | TParsed): boolean; + // (undocumented) + parse(serializedValue: TSerialized): NoInfer_2; + // (undocumented) + serialize(parsedValue: TParsed): NoInfer_2; + } +} + +// @public (undocumented) +export class Scalar { + constructor(options: Scalar.Options); + // (undocumented) + coerceToParsed(value: TSerialized | TParsed): TParsed; + // (undocumented) + coerceToSerialized(value: TSerialized | TParsed): TSerialized; + // (undocumented) + static fromGraphQLScalarType(scalarType: GraphQLScalarType, options?: Pick, NoInfer_2>, "is">): Scalar; + // (undocumented) + is(value: TSerialized | TParsed): value is TParsed; + // (undocumented) + parse(value: TSerialized): TParsed; + // (undocumented) + serialize(value: TParsed): TSerialized; +} + +// @public (undocumented) +type ScalarNames = keyof KnownScalars | (string extends keyof ApolloCache.Scalars ? string & {} : never); + // @public (undocumented) type StorageType = Record; @@ -1013,9 +1109,10 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/inmemory/policies.ts:173:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/policies.ts:173:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:135:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:179:3 - (ae-forgotten-export) The symbol "ScalarNames" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 9a053ae128d..0626d3e84a4 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -52,6 +52,8 @@ import type { IgnoreModifier } from '@apollo/client/cache'; import type { Incremental } from '@apollo/client/incremental'; import { InMemoryCache } from '@apollo/client/cache'; import { InMemoryCacheConfig } from '@apollo/client/cache'; +import { InputObjectConfig } from '@apollo/client/cache'; +import { InputObjectsOption } from '@apollo/client/cache'; import type { InternalTypes as InternalTypes_2 } from '@apollo/client'; import type { InteropObservable } from 'rxjs'; import type { IsAny } from '@apollo/client/utilities/internal'; @@ -87,6 +89,7 @@ import type { Reference as Reference_2 } from '@apollo/client/cache'; import { RequestHandler } from '@apollo/client/link'; import { resetCaches } from 'graphql-tag'; import { rewriteURIForGET } from '@apollo/client/link/http'; +import { Scalar } from '@apollo/client/cache'; import { selectHttpOptionsAndBody } from '@apollo/client/link/http'; import { selectHttpOptionsAndBodyInternal } from '@apollo/client/link/http'; import { selectURI } from '@apollo/client/link/http'; @@ -659,6 +662,10 @@ export { InMemoryCache } export { InMemoryCacheConfig } +export { InputObjectConfig } + +export { InputObjectsOption } + // Warning: (ae-forgotten-export) The symbol "RefetchQueriesIncludeShorthand" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -1000,7 +1007,7 @@ class QueryManager { // (undocumented) broadcastQueries(): void; // (undocumented) - get cache(): ApolloCache; + get cache(): Cache_2.Implementation; // (undocumented) clearStore(options?: Cache_2.ResetOptions): Promise; // (undocumented) @@ -1244,6 +1251,8 @@ export { resetCaches } export { rewriteURIForGET } +export { Scalar } + export { selectHttpOptionsAndBody } export { selectHttpOptionsAndBodyInternal } diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index 43e70c0c18c..b9a6b1eaa1a 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -33,6 +33,7 @@ import type { SelectionSetNode } from 'graphql'; import { StrongCache } from '@wry/caches'; import type { Subscription } from 'rxjs'; import type { Trie } from '@wry/trie'; +import type { TypeNode } from 'graphql'; import type { TypeOverrides } from '@apollo/client'; import { WeakCache } from '@wry/caches'; @@ -343,6 +344,9 @@ export const getStoreKeyName: ((fieldName: string, args?: Record | setStringify(s: typeof storeKeyNameStringify): (value: any) => string; }; +// @internal @deprecated (undocumented) +export function getUnwrappedType(node: TypeNode): string; + // @public (undocumented) const globalCaches: { print?: () => number; @@ -370,6 +374,13 @@ export function isDocumentNode(value: unknown): value is DocumentNode; // @internal @deprecated (undocumented) export function isField(selection: SelectionNode): selection is FieldNode; +// @public +export type IsLooselyEqual = [ +A +] extends [B] ? [ +B +] extends [A] ? true : false : false; + // @internal @deprecated (undocumented) export function isNonEmptyArray(value: ArrayLike | null | undefined): value is Array; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 76fac104fc9..459fd726f86 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -15,6 +15,7 @@ import type { FormattedExecutionResult } from 'graphql'; import type { FragmentDefinitionNode } from 'graphql'; import { gql } from 'graphql-tag'; import type { GraphQLFormattedError } from 'graphql'; +import type { GraphQLScalarType } from 'graphql'; import type { InlineFragmentNode } from 'graphql'; import type { InteropObservable } from 'rxjs'; import type { NextNotification } from 'rxjs'; @@ -41,9 +42,17 @@ export namespace ApolloCache { // Warning: (ae-forgotten-export) The symbol "NoInfer_2" needs to be exported by the entry point index.d.ts export type FromOptionValue = StoreObject | Reference | FragmentType> | string; // (undocumented) + export type GetScalarType = ApolloCache.Scalars[TKey] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + // (undocumented) export interface ObservableFragment extends Observable> { getCurrentResult: () => ApolloCache.WatchFragmentResult; } + // (undocumented) + export interface Scalars { + } export interface WatchFragmentOptions { fragment: DocumentNode | TypedDocumentNode; fragmentName?: string; @@ -91,6 +100,8 @@ export abstract class ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) + getScalar(key: TKey): ApolloCache.GetScalarType | undefined; + // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) lookupFragment(fragmentName: string): FragmentDefinitionNode | null; @@ -116,6 +127,8 @@ export abstract class ApolloCache { abstract reset(options?: Cache_2.ResetOptions): Promise; resolvesClientField?(typename: string, fieldName: string): boolean; abstract restore(serializedState: unknown): this; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2): TVariables; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2 | undefined): TVariables | undefined; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; // (undocumented) @@ -1321,6 +1334,7 @@ export type FieldPolicy; merge?: FieldMergeFunction | boolean; + scalar?: ScalarNames; }; // @public (undocumented) @@ -1557,9 +1571,33 @@ type InferContextValueFromResolvers = TResolvers extends { [field: string]: LocalState.Resolver; }) ? unknown extends TContext ? DefaultContext : TContext : DefaultContext : DefaultContext; +// @public (undocumented) +export namespace InMemoryCache { + // Warning: (ae-forgotten-export) The symbol "KnownScalars" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "IsLooselyEqual" needs to be exported by the entry point index.d.ts + // + // (undocumented) + export type ScalarsOption = { + [ScalarName in keyof KnownScalars as IsLooselyEqual extends true ? ScalarName : never]?: KnownScalars[ScalarName] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + } & { + [ScalarName in keyof KnownScalars as IsLooselyEqual extends true ? never : ScalarName]: KnownScalars[ScalarName] extends ({ + serialized: infer TSerialized; + parsed: infer TParsed; + }) ? Scalar : never; + } & (ApolloCache.Scalars extends (Record) ? Record> : {}); +} + // @public (undocumented) export class InMemoryCache extends ApolloCache { - constructor(config?: InMemoryCacheConfig); + constructor(...args: {} extends InMemoryCache.ScalarsOption ? [ + config?: InMemoryCacheConfig + ] : [config: InMemoryCacheConfig]); // (undocumented) readonly assumeImmutableResults = true; batch(options: Cache_2.BatchOptions): TUpdateResult; @@ -1586,6 +1624,8 @@ export class InMemoryCache extends ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getInMemoryCacheMemoryInternals; // (undocumented) + getScalar(key: TKey): ApolloCache.GetScalarType extends (Scalar) ? IsLooselyEqual extends true ? ApolloCache.GetScalarType | undefined : ApolloCache.GetScalarType : never; + // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) lookupFragment(fragmentName: string): FragmentDefinitionNode | null; @@ -1615,6 +1655,8 @@ export class InMemoryCache extends ApolloCache { restore(data: NormalizedCacheObject): this; // (undocumented) retain(rootId: string, optimistic?: boolean): number; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2): TVariables; + serializeVariables(document: DocumentNode | TypedDocumentNode, variables: NoInfer_2 | undefined): TVariables | undefined; // (undocumented) transformDocument(document: DocumentNode): DocumentNode; // (undocumented) @@ -1624,17 +1666,30 @@ export class InMemoryCache extends ApolloCache { } // @public (undocumented) -export interface InMemoryCacheConfig extends ApolloReducerConfig { - // Warning: (ae-forgotten-export) The symbol "FragmentRegistryAPI" needs to be exported by the entry point index.d.ts - // - // (undocumented) - fragments?: FragmentRegistryAPI; - // (undocumented) +export type InMemoryCacheConfig = ApolloReducerConfig & { + resultCaching?: boolean; possibleTypes?: PossibleTypesMap; + typePolicies?: TypePolicies; + fragments?: FragmentRegistryAPI; + inputObjects?: InputObjectsOption; +} & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { + scalars?: Record; +} : { + scalars?: InMemoryCache.ScalarsOption; +} : { + scalars: InMemoryCache.ScalarsOption; +}); + +// @public (undocumented) +export interface InputObjectConfig { // (undocumented) - resultCaching?: boolean; + fields: Record; +} + +// @public (undocumented) +export interface InputObjectsOption { // (undocumented) - typePolicies?: TypePolicies; + [inputObjectName: string]: InputObjectConfig; } // Warning: (ae-forgotten-export) The symbol "RefetchQueriesIncludeShorthand" needs to be exported by the entry point index.d.ts @@ -1676,6 +1731,13 @@ const _invalidateModifier: unique symbol; // @internal @deprecated (undocumented) type IsAny = 0 extends 1 & T ? true : false; +// @public +type IsLooselyEqual = [ +A +] extends [B] ? [ +B +] extends [A] ? true : false : false; + // @public export function isNetworkRequestSettled(networkStatus?: NetworkStatus): boolean; @@ -1706,6 +1768,11 @@ type KeyFieldsFunction = (object: Readonly, context: KeyFieldsConte // @public (undocumented) type KeySpecifier = ReadonlyArray; +// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type KnownScalars = RemoveIndexSignature; + // @public (undocumented) class Layer extends EntityStore { constructor(id: string, parent: EntityStore, replay: (layer: EntityStore) => any, group: CacheGroup); @@ -1884,8 +1951,6 @@ interface MaskOperationOptions { // @public export type MaybeMasked = ApplyHKTImplementationWithDefault; -// Warning: (ae-forgotten-export) The symbol "RemoveIndexSignature" needs to be exported by the entry point index.d.ts -// // @public (undocumented) type MaybeRequireContextFunction = {} extends RemoveIndexSignature ? {} : { context: LocalState.ContextFunction; @@ -2321,6 +2386,8 @@ class Policies { getMergeFunction(parentTypename: string | undefined, fieldName: string, childTypename: string | undefined): FieldMergeFunction | undefined; // (undocumented) getReadFunction(typename: string | undefined, fieldName: string): FieldReadFunction | undefined; + // (undocumented) + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // Warning: (ae-forgotten-export) The symbol "FieldSpecifier" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -2418,7 +2485,7 @@ class QueryManager { // (undocumented) broadcastQueries(): void; // (undocumented) - get cache(): ApolloCache; + get cache(): Cache_2.Implementation; // (undocumented) clearStore(options?: Cache_2.ResetOptions): Promise; // (undocumented) @@ -2753,6 +2820,39 @@ class Root extends EntityStore { // @public (undocumented) type SafeReadonly = T extends object ? Readonly : T; +// @public (undocumented) +export namespace Scalar { + // (undocumented) + export interface Options { + // (undocumented) + is?(value: TSerialized | TParsed): boolean; + // (undocumented) + parse(serializedValue: TSerialized): NoInfer_2; + // (undocumented) + serialize(parsedValue: TParsed): NoInfer_2; + } +} + +// @public (undocumented) +export class Scalar { + constructor(options: Scalar.Options); + // (undocumented) + coerceToParsed(value: TSerialized | TParsed): TParsed; + // (undocumented) + coerceToSerialized(value: TSerialized | TParsed): TSerialized; + // (undocumented) + static fromGraphQLScalarType(scalarType: GraphQLScalarType, options?: Pick, NoInfer_2>, "is">): Scalar; + // (undocumented) + is(value: TSerialized | TParsed): value is TParsed; + // (undocumented) + parse(value: TSerialized): TParsed; + // (undocumented) + serialize(value: TParsed): TSerialized; +} + +// @public (undocumented) +type ScalarNames = keyof KnownScalars | (string extends keyof ApolloCache.Scalars ? string & {} : never); + // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -3068,11 +3168,13 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/core/cache.ts:126:11 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/policies.ts:101:3 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/policies.ts:173:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/policies.ts:173:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:135:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/core/cache.ts:127:11 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:104:3 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeySpecifier" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:176:3 - (ae-forgotten-export) The symbol "KeyArgsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/policies.ts:179:3 - (ae-forgotten-export) The symbol "ScalarNames" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:154:3 - (ae-forgotten-export) The symbol "FragmentRegistryAPI" needs to be exported by the entry point index.d.ts // src/core/ApolloClient.ts:201:5 - (ae-forgotten-export) The symbol "IgnoreModifier" needs to be exported by the entry point index.d.ts // src/core/ApolloClient.ts:635:5 - (ae-forgotten-export) The symbol "NextFetchPolicyContext" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:375:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts diff --git a/.changeset/cold-comics-add.md b/.changeset/cold-comics-add.md new file mode 100644 index 00000000000..cb5a23a828d --- /dev/null +++ b/.changeset/cold-comics-add.md @@ -0,0 +1,18 @@ +--- +"@apollo/client": minor +--- + +Adds `Scalar.fromGraphQLScalarType` helper to create a `Scalar` instance from an existing graphql.js `GraphQLScalarType`. + +```ts +import { GraphQLScalarType } from "graphql"; +import { Scalar } from "@apollo/client"; + +const dateTimeScalarType = new GraphQLScalarType({ + // ... +}); + +const dateTimeScalar = Scalar.fromGraphQLScalarType(dateTimeScalarType, { + is: (value) => value instanceof Date, +}); +``` diff --git a/.changeset/eighty-files-sort.md b/.changeset/eighty-files-sort.md new file mode 100644 index 00000000000..a27891120d5 --- /dev/null +++ b/.changeset/eighty-files-sort.md @@ -0,0 +1,36 @@ +--- +"@apollo/client": minor +--- + +Adds the plumbing and types implementation for declaring custom scalars and configuring custom scalars in `InMemoryCache`. + +You can declare custom scalar types with declaration merging on the `ApolloCache.Scalars` interface: + +```ts +// apollo.d.ts +import "@apollo/client"; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars { + Date: { serialized: string; parsed: Date }; + } + } +} +``` + +This enables the `scalars` option in `InMemoryCache`: + +```ts +import { Scalar } from "@apollo/client"; + +const cache = new InMemoryCache({ + scalars: { + Date: new Scalar({ + parse: (dateString) => new Date(dateString), + serialize: (date) => date.toISOString(), + is: (value) => value instanceof Date, + }), + }, +}); +``` diff --git a/.changeset/strong-shoes-sell.md b/.changeset/strong-shoes-sell.md new file mode 100644 index 00000000000..25b7bee91e4 --- /dev/null +++ b/.changeset/strong-shoes-sell.md @@ -0,0 +1,30 @@ +--- +"@apollo/client": minor +--- + +Adds a `scalar` option to `InMemoryCache` field policies that tells the cache which scalar to use when parsing or serializing the field value. + +```ts +import { Scalar } from "@apollo/client"; + +new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + parse: (dateString) => new Date(dateString), + serialize: (date) => date.toISOString(), + }), + }, + typePolicies: { + Event: { + fields: { + startTime: { + // Parse this field using the DateTime scalar + scalar: "DateTime", + }, + }, + }, + }, +}); +``` + +This scalar definition is now used to properly parse or serialize the field value for cache reads and writes as well as `cache.extract()` and `cache.restore()`. diff --git a/.changeset/thin-tips-fold.md b/.changeset/thin-tips-fold.md new file mode 100644 index 00000000000..a863f65d15d --- /dev/null +++ b/.changeset/thin-tips-fold.md @@ -0,0 +1,46 @@ +--- +"@apollo/client": minor +--- + +Automatically serialize variables that include custom scalar values. This includes cache reads and writes as well as requests to the network. + +For more complex input objects, a new `inputObjects` option is available to `InMemoryCache` that specifies where nested scalar fields are found. + +```ts +const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + parse: (value) => new Date(value), + serialize: (value) => value.toISOString(), + is: (value) => value instanceof Date, + }), + }, + inputObjects: { + EventFilter: { + fields: { + date: "DateTime", + }, + }, + }, +}); + +const client = new ApolloClient({ cache, link }); + +await client.query({ + query: gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `, + variables: { + filter: { + date: new Date("2026-01-01T00:00:00.000Z"), + }, + }, +}); + +// The link receives: +// { filter: { date: "2026-01-01T00:00:00.000Z" } } +``` diff --git a/.changeset/wet-experts-end.md b/.changeset/wet-experts-end.md new file mode 100644 index 00000000000..1a223d424f1 --- /dev/null +++ b/.changeset/wet-experts-end.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Adds the `getScalar` abstract method to `ApolloCache` that cache subclasses override to provide scalar behavior to Apollo Client. Defaults to unconditionally return `undefined` if not specified. diff --git a/.circleci/config.yml b/.circleci/config.yml index a4edb467524..4f9ccc56a3c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,7 +38,8 @@ jobs: - run: npm ci - run: npm run docmodel - run: npm run build - - run: npm run lint + - run: npm run lint:docs + - run: npm run lint:src Formatting: docker: diff --git a/config/jest.config.ts b/config/jest.config.ts index 92179a57b1b..b06a01dd9e9 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -43,6 +43,7 @@ const reactSharedTestFileIgnoreList = [ ignoreTSFiles, "src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx", "src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx", + "src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx", ]; const react17TestFileIgnoreList = [ @@ -60,6 +61,7 @@ const react17TestFileIgnoreList = [ "src/react/hooks/__tests__/useLoadableQuery/*", "src/react/hooks/__tests__/useQueryRefHandlers.test.tsx", "src/react/query-preloader/__tests__/createQueryPreloader.test.tsx", + "src/react/query-preloader/__tests__/createQueryPreloader/*", "src/react/ssr/__tests__/prerenderStatic.test.tsx", "src/react/ssr/__tests__/useQueryEndToEnd.test.tsx", ]; diff --git a/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts new file mode 100644 index 00000000000..02e7650e904 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts @@ -0,0 +1,237 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; +declare const maybeDate: string | Date; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars extends Record { + DateTime: { serialized: string; parsed: Date }; + } + } +} + +test("requires the scalars option for a declared transforming scalar", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `scalars` is required. + new InMemoryCache({}); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => new Date(value), + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.getTime(), + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.toISOString(); + }, + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => { + value; + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error wrong parsed type + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value.length, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error missing return + parse: (value) => { + new Date(value); + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => { + String(value); + }, + }), + }, + }); +}); + +test("is narrows to the parsed type when used as a type guard", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value instanceof Date; + }, + }), + }, + }); + + const scalar = cache.getScalar("DateTime"); + + if (scalar.is(maybeDate)) { + expectTypeOf(maybeDate).toEqualTypeOf(); + } else { + expectTypeOf(maybeDate).toEqualTypeOf(); + } +}); + +test("InMemoryCache.getScalar returns the resolved scalar for a declared scalar", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-any/differentTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-any/differentTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/differentTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-any/empty/index.ts b/integration-tests/type-tests/customScalars/all-any/empty/index.ts new file mode 100644 index 00000000000..91011b42af7 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/empty/index.ts @@ -0,0 +1,174 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record {} + } +} + +test("does not require the scalars option", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize accepts any return value", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + value.trim(); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse accepts any return value", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value.length, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => { + value.trim(); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-any/empty/tsconfig.json b/integration-tests/type-tests/customScalars/all-any/empty/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/empty/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts new file mode 100644 index 00000000000..b75974b741c --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts @@ -0,0 +1,250 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars extends Record { + RelativeDate: { serialized: string; parsed: string }; + JSONObject: { serialized: unknown; parsed: unknown }; + } + } +} + +test("does not require the scalars option when every scalar matches", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + JSONObject: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.length, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.trim(); + }, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => { + String(value); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error wrong parsed type + parse: (value) => value.length, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => String(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error missing return + parse: (value) => { + value.trim(); + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => { + String(value); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-any/matchingTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-any/matchingTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/matchingTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-any/mixed/index.ts b/integration-tests/type-tests/customScalars/all-any/mixed/index.ts new file mode 100644 index 00000000000..c523b7dce31 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/mixed/index.ts @@ -0,0 +1,156 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars extends Record { + DateTime: { serialized: string; parsed: Date }; + RelativeDate: { serialized: string; parsed: string }; + } + } +} + +test("requires only the scalars whose serialized and parsed types differ", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + // @ts-expect-error `DateTime` is missing from `scalars`. + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("infers each scalar's serialized and parsed types independently", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("getScalar resolves each scalar according to its declaration", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + endDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-any/mixed/tsconfig.json b/integration-tests/type-tests/customScalars/all-any/mixed/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/mixed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts new file mode 100644 index 00000000000..8fdf281969a --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts @@ -0,0 +1,61 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + DateTime: { serialized: string; parsed: Date }; + } + } +} + +// `DateTime` is a required scalar since its serialized/parsed types differ, but its +// `Date` parsed type is not assignable to the interface it extends so there is +// no valid config where the cache can be constructed. +test("a transforming scalar that conflicts with the index cannot be configured", () => { + // @ts-expect-error `scalars` is required + new InMemoryCache(); + // @ts-expect-error `scalars` is required + new InMemoryCache({}); + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + // @ts-expect-error `DateTime` is not assignable to index signature + scalars: { + DateTime: new Scalar({ + serialize: (value: Date) => value.toISOString(), + parse: (value: string) => new Date(value), + }), + }, + }); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + // @ts-expect-error `DateTime` is not assignable to index signature + scalars: { + DateTime: new Scalar({ + serialize: (value: Date) => value.toISOString(), + parse: (value: string) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-structured/differentTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-structured/differentTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/differentTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-structured/empty/index.ts b/integration-tests/type-tests/customScalars/all-structured/empty/index.ts new file mode 100644 index 00000000000..ef804722237 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/empty/index.ts @@ -0,0 +1,172 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record {} + } +} + +test("does not require the scalars option", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.trim(); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + serialize: (value) => value, + // @ts-expect-error wrong parsed type + parse: (value) => value.length, + }), + }, + }); + + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + serialize: (value) => value, + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + serialize: (value) => value, + // @ts-expect-error missing return + parse: (value) => { + value.trim(); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-structured/empty/tsconfig.json b/integration-tests/type-tests/customScalars/all-structured/empty/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/empty/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts new file mode 100644 index 00000000000..51adf34b6c0 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts @@ -0,0 +1,135 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + RelativeDate: { serialized: string; parsed: string }; + JSONObject: { serialized: unknown; parsed: unknown }; + } + } +} + +test("does not require the scalars option when every scalar matches", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + // @ts-expect-error JSONObject doesn't match serialized/parsed type + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("a scalar whose types match the index can be configured", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); +}); + +test("a scalar whose types do not match the index cannot be configured", () => { + new InMemoryCache({ + // @ts-expect-error `JSONObject`'s `unknown` types are not assignable to the string index. + scalars: { + JSONObject: new Scalar({ + serialize: (value: unknown) => value, + parse: (value: unknown) => value, + }), + }, + }); +}); + +test("getScalar resolves each scalar according to its declaration", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-structured/matchingTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts b/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts new file mode 100644 index 00000000000..c8def17feb5 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts @@ -0,0 +1,71 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + DateTime: { serialized: string; parsed: Date }; + RelativeDate: { serialized: string; parsed: string }; + } + } +} + +// `RelativeDate` matches the `{ serialized: string; parsed: string }` index +// signature and works as expected. `DateTime` however is not assignable to +// index signature it extends, but since it is required, there is no valid +// config where the cache can be constructed. +test("a transforming scalar conflicting with the index blocks configuration", () => { + // @ts-expect-error `scalars` is required + new InMemoryCache(); + // @ts-expect-error `scalars` is required + new InMemoryCache({}); + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + // @ts-expect-error `DateTime` is not assignable to index signature + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + // @ts-expect-error `DateTime` is not assignable to index signature + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + endDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-structured/mixed/tsconfig.json b/integration-tests/type-tests/customScalars/all-structured/mixed/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/mixed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts new file mode 100644 index 00000000000..f31160140e0 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts @@ -0,0 +1,238 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; +declare const maybeDate: string | Date; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + DateTime: { serialized: string; parsed: Date }; + } + } +} + +test("requires the scalars option for a declared transforming scalar", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `scalars` is required. + new InMemoryCache({}); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.getTime(), + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.toISOString(); + }, + parse: (value) => new Date(value), + }), + Unknown: new Scalar({ + serialize: (value) => { + value; + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error wrong parsed type + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => String(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error missing return + parse: (value) => { + new Date(value); + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => { + String(value); + }, + }), + }, + }); +}); + +test("is narrows to the parsed type when used as a type guard", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value instanceof Date; + }, + }), + }, + }); + + const scalar = cache.getScalar("DateTime"); + + if (scalar.is(maybeDate)) { + expectTypeOf(maybeDate).toEqualTypeOf(); + } else { + expectTypeOf(maybeDate).toEqualTypeOf(); + } +}); + +test("InMemoryCache.getScalar returns the resolved scalar for a declared scalar", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-unknown/differentTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts b/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts new file mode 100644 index 00000000000..8f1c9eac21b --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts @@ -0,0 +1,178 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record {} + } +} + +test("does not require the scalars option", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error value is unknown + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + // @ts-expect-error value is unknown + value.trim(); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error value is unknown + parse: (value) => value.length, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => { + // @ts-expect-error value is unknown + value.trim(); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-unknown/empty/tsconfig.json b/integration-tests/type-tests/customScalars/all-unknown/empty/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/empty/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts new file mode 100644 index 00000000000..70fa39278da --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts @@ -0,0 +1,252 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + RelativeDate: { serialized: string; parsed: string }; + JSONObject: { serialized: unknown; parsed: unknown }; + } + } +} + +test("does not require the scalars option when every scalar matches", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + JSONObject: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.length, + parse: (value) => value, + }), + Unknown: new Scalar({ + // @ts-expect-error value is unknown + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.trim(); + }, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => { + String(value); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error wrong parsed type + parse: (value) => value.length, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => String(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error missing return + parse: (value) => { + value.trim(); + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => { + String(value); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/tsconfig.json b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts b/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts new file mode 100644 index 00000000000..c6e87146a4f --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts @@ -0,0 +1,157 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client" { + namespace ApolloCache { + interface Scalars + extends Record { + DateTime: { serialized: string; parsed: Date }; + RelativeDate: { serialized: string; parsed: string }; + } + } +} + +test("requires only the scalars whose serialized and parsed types differ", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + // @ts-expect-error `DateTime` is missing from `scalars`. + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("infers each scalar's serialized and parsed types independently", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + Unknown: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("getScalar resolves each scalar according to its declaration", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("Unknown")).toEqualTypeOf< + Scalar | undefined + >(); +}); + +test("allows any scalar name in field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + endDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "Unknown", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/all-unknown/mixed/tsconfig.json b/integration-tests/type-tests/customScalars/all-unknown/mixed/tsconfig.json new file mode 100644 index 00000000000..3e25d9966cf --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/mixed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/differentTypes/index.ts b/integration-tests/type-tests/customScalars/differentTypes/index.ts new file mode 100644 index 00000000000..a8c6ec70bea --- /dev/null +++ b/integration-tests/type-tests/customScalars/differentTypes/index.ts @@ -0,0 +1,227 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; +import { GraphQLScalarType } from "graphql"; + +declare function test(name: string, fn: () => void): void; +declare const maybeDate: string | Date; + +declare module "@apollo/client/cache" { + namespace ApolloCache { + interface Scalars { + DateTime: { serialized: string; parsed: Date }; + } + } +} + +test("requires the scalars option for a declared transforming scalar", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `scalars` is required. + new InMemoryCache({}); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + // @ts-expect-error not a declared scalar + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + }, + }); +}); + +test("fromGraphQLScalarType infers the serialized and parsed types", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "DateTime", + serialize: (value) => { + if (!(value instanceof Date)) { + throw new TypeError("Expected a Date"); + } + + return value.toISOString(); + }, + parseValue: (value) => { + if (typeof value !== "string") { + throw new TypeError("Expected a string"); + } + + return new Date(value); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar, { + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value instanceof Date; + }, + }); + + expectTypeOf(scalar).toEqualTypeOf>(); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.getTime(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.toISOString(); + }, + parse: (value) => new Date(value), + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error wrong parsed type + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + // @ts-expect-error missing return + parse: (value) => { + new Date(value); + }, + }), + }, + }); +}); + +test("is narrows to the parsed type when used as a type guard", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value instanceof Date; + }, + }), + }, + }); + + const scalar = cache.getScalar("DateTime"); + + if (scalar.is(maybeDate)) { + expectTypeOf(maybeDate).toEqualTypeOf(); + } else { + expectTypeOf(maybeDate).toEqualTypeOf(); + } +}); + +test("InMemoryCache.getScalar returns the resolved scalar for a declared scalar", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + // @ts-expect-error not a declared scalar + cache.getScalar("Unknown"); +}); + +test("allows only defined scalars field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + metadata: { + // @ts-expect-error scalar not registered + scalar: "JSONObject", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/differentTypes/tsconfig.json b/integration-tests/type-tests/customScalars/differentTypes/tsconfig.json new file mode 100644 index 00000000000..6e0a5593323 --- /dev/null +++ b/integration-tests/type-tests/customScalars/differentTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/empty/index.ts b/integration-tests/type-tests/customScalars/empty/index.ts new file mode 100644 index 00000000000..68d2d110aa4 --- /dev/null +++ b/integration-tests/type-tests/customScalars/empty/index.ts @@ -0,0 +1,37 @@ +import { InMemoryCache } from "@apollo/client/cache"; + +declare function test(name: string, fn: () => void): void; + +test("does not require or allow scalar configuration", () => { + new InMemoryCache(); + new InMemoryCache({}); + + new InMemoryCache({ + scalars: { + // @ts-expect-error: Scalar types must be declared in ApolloCache.Scalars before usage. See https://www.apollographql.com/docs/react/data/typescript#declaring-scalar-types. + DateTime: { serialize: () => "", parse: () => "" }, + }, + }); +}); + +test("getScalar cannot be called without a declared scalar", () => { + const cache = new InMemoryCache(); + + // @ts-expect-error no scalars are declared + cache.getScalar("DateTime"); +}); + +test("does not allow any scalar name in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + // @ts-expect-error no scalars are declared + scalar: "DateTime", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/empty/tsconfig.json b/integration-tests/type-tests/customScalars/empty/tsconfig.json new file mode 100644 index 00000000000..6e0a5593323 --- /dev/null +++ b/integration-tests/type-tests/customScalars/empty/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/matchingTypes/index.ts new file mode 100644 index 00000000000..0ae82d270ef --- /dev/null +++ b/integration-tests/type-tests/customScalars/matchingTypes/index.ts @@ -0,0 +1,205 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client/cache" { + namespace ApolloCache { + interface Scalars { + RelativeDate: { serialized: string; parsed: string }; + JSONObject: { serialized: unknown; parsed: unknown }; + } + } +} + +test("does not require the scalars option when every scalar matches", () => { + new InMemoryCache(); + new InMemoryCache({}); + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + JSONObject: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + // @ts-expect-error not a declared scalar + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("serialize receives the parsed type and parse receives the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + JSONObject: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("serialize must return the serialized type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error wrong serialized type + serialize: (value) => value.length, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error cannot return undefined + serialize: (value) => undefined, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + // @ts-expect-error missing return + serialize: (value) => { + value.trim(); + }, + parse: (value) => value, + }), + }, + }); +}); + +test("parse must return the parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error wrong parsed type + parse: (value) => value.length, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error cannot return undefined + parse: (value) => undefined, + }), + }, + }); + + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + // @ts-expect-error missing return + parse: (value) => { + value.trim(); + }, + }), + }, + }); +}); + +test("is receives the combined serialized and parsed type", () => { + new InMemoryCache({ + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + is: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return typeof value === "string"; + }, + }), + }, + }); +}); + +test("getScalar returns the resolved scalar or undefined", () => { + const cache = new InMemoryCache(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + expectTypeOf(cache.getScalar("JSONObject")).toEqualTypeOf< + Scalar | undefined + >(); + + // @ts-expect-error not a declared scalar + cache.getScalar("Unknown"); +}); + +test("allows only defined scalars in field policies", () => { + new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "RelativeDate", + }, + metadata: { + scalar: "JSONObject", + }, + unknown: { + // @ts-expect-error scalar not registered + scalar: "DateTime", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/matchingTypes/tsconfig.json b/integration-tests/type-tests/customScalars/matchingTypes/tsconfig.json new file mode 100644 index 00000000000..6e0a5593323 --- /dev/null +++ b/integration-tests/type-tests/customScalars/matchingTypes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/customScalars/mixed/index.ts b/integration-tests/type-tests/customScalars/mixed/index.ts new file mode 100644 index 00000000000..8b39d3060ca --- /dev/null +++ b/integration-tests/type-tests/customScalars/mixed/index.ts @@ -0,0 +1,147 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { expectTypeOf } from "expect-type"; + +declare function test(name: string, fn: () => void): void; + +declare module "@apollo/client/cache" { + namespace ApolloCache { + interface Scalars { + DateTime: { serialized: string; parsed: Date }; + RelativeDate: { serialized: string; parsed: string }; + } + } +} + +test("requires only the scalars whose serialized and parsed types differ", () => { + // @ts-expect-error `scalars` is required. + new InMemoryCache(); + + // @ts-expect-error `DateTime` is missing from `scalars`. + new InMemoryCache({ scalars: {} }); + + new InMemoryCache({ + // @ts-expect-error `DateTime` is missing from `scalars`. + scalars: { + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); + + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + RelativeDate: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + // @ts-expect-error not a declared scalar + Unknown: new Scalar({ + serialize: (value) => value, + parse: (value) => value, + }), + }, + }); +}); + +test("infers each scalar's serialized and parsed types independently", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value.toISOString(); + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return new Date(value); + }, + }), + RelativeDate: new Scalar({ + serialize: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + parse: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + }), + }, + }); +}); + +test("getScalar resolves each scalar according to its declaration", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expectTypeOf(cache.getScalar("DateTime")).toEqualTypeOf< + Scalar + >(); + + expectTypeOf(cache.getScalar("RelativeDate")).toEqualTypeOf< + Scalar | undefined + >(); + + // @ts-expect-error not a declared scalar + cache.getScalar("Unknown"); +}); + +test("allows only defined scalars in field policies", () => { + new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "DateTime", + }, + endDate: { + scalar: "RelativeDate", + }, + metadata: { + // @ts-expect-error scalar not registered + scalar: "JSONObject", + }, + }, + }, + }, + }); +}); diff --git a/integration-tests/type-tests/customScalars/mixed/tsconfig.json b/integration-tests/type-tests/customScalars/mixed/tsconfig.json new file mode 100644 index 00000000000..6e0a5593323 --- /dev/null +++ b/integration-tests/type-tests/customScalars/mixed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../../tsconfig.src.json" + } + ] +} diff --git a/integration-tests/type-tests/tsconfig.json b/integration-tests/type-tests/tsconfig.json index ed97b631388..a74b1ce3ac4 100644 --- a/integration-tests/type-tests/tsconfig.json +++ b/integration-tests/type-tests/tsconfig.json @@ -60,6 +60,54 @@ { "path": "./defaultOptions/mutate/errorPolicyIgnore/tsconfig.json" }, + { + "path": "./customScalars/all-unknown/differentTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-unknown/empty/tsconfig.json" + }, + { + "path": "./customScalars/all-unknown/matchingTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-unknown/mixed/tsconfig.json" + }, + { + "path": "./customScalars/all-any/differentTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-any/empty/tsconfig.json" + }, + { + "path": "./customScalars/all-any/matchingTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-any/mixed/tsconfig.json" + }, + { + "path": "./customScalars/all-structured/differentTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-structured/empty/tsconfig.json" + }, + { + "path": "./customScalars/all-structured/matchingTypes/tsconfig.json" + }, + { + "path": "./customScalars/all-structured/mixed/tsconfig.json" + }, + { + "path": "./customScalars/empty/tsconfig.json" + }, + { + "path": "./customScalars/differentTypes/tsconfig.json" + }, + { + "path": "./customScalars/matchingTypes/tsconfig.json" + }, + { + "path": "./customScalars/mixed/tsconfig.json" + }, { "path": "./signatures/modern/tsconfig.json" }, diff --git a/package.json b/package.json index 25f85060c83..fcb05995810 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,9 @@ "check:format": "prettier --check .", "ci:precheck": "node config/precheck.ts", "format": "prettier --write .", - "lint": "EXTENDED_RULES=1 eslint --max-warnings 0 src docs", + "lint": "npm run lint:docs && npm run lint:src", + "lint:docs": "EXTENDED_RULES=1 eslint --max-warnings 0 docs", + "lint:src": "EXTENDED_RULES=1 eslint --max-warnings 0 src", "test": "node --expose-gc --experimental-import-meta-resolve --disable-warning=ExperimentalWarning ./node_modules/jest/bin/jest.js --config ./config/jest.config.ts", "test:type-benches": "find src -name '*.bench.ts' | xargs -n1 tsx", "test:debug": "node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.ts --runInBand --testTimeout 99999 --logHeapUsage", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d18aeac8a9d..2b32dcaeafb 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -17,6 +17,7 @@ Array [ "Observable", "ObservableQuery", "RefetchEventManager", + "Scalar", "ServerError", "ServerParseError", "UnconventionalError", @@ -59,6 +60,7 @@ Array [ "InMemoryCache", "MissingFieldError", "Policies", + "Scalar", "cacheSlot", "canonicalStringify", "createFragmentRegistry", @@ -86,6 +88,7 @@ Array [ "Observable", "ObservableQuery", "RefetchEventManager", + "Scalar", "ServerError", "ServerParseError", "UnconventionalError", @@ -367,16 +370,20 @@ Array [ "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", + "dateScalar", + "dateTimeScalar", "enableFakeTimers", "executeSchemaGraphQL17Alpha2", "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", "friendListSchemaGraphQL17Alpha2", "friendListSchemaGraphQL17Alpha9", + "jsonObjectScalar", "markAsStreaming", "mockDefer20220824", "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "priceScalar", "promiseWithResolvers", "renderAsync", "renderHookAsync", @@ -462,6 +469,7 @@ Array [ "getOperationName", "getQueryDefinition", "getStoreKeyName", + "getUnwrappedType", "graphQLResultHasError", "hasDirectives", "hasForcedResolvers", diff --git a/src/cache/core/Scalar.ts b/src/cache/core/Scalar.ts new file mode 100644 index 00000000000..e8e852ccc73 --- /dev/null +++ b/src/cache/core/Scalar.ts @@ -0,0 +1,58 @@ +import type { GraphQLScalarType } from "graphql"; + +import type { NoInfer } from "@apollo/client/utilities/internal"; + +export declare namespace Scalar { + export interface Options { + // We use method syntax to ensure the functions are bivariant. This lets + // users declare scalars using + // `extends Record` while + // allowing specific scalar overrides. + parse(serializedValue: TSerialized): NoInfer; + serialize(parsedValue: TParsed): NoInfer; + is?(value: TSerialized | TParsed): boolean; + } +} + +export class Scalar { + private options: Scalar.Options; + + static fromGraphQLScalarType( + scalarType: GraphQLScalarType, + options?: Pick, NoInfer>, "is"> + ): Scalar { + return new Scalar({ + ...options, + parse: scalarType.parseValue, + serialize: scalarType.serialize, + }); + } + + constructor(options: Scalar.Options) { + this.options = options; + } + + parse(value: TSerialized): TParsed { + return this.options.parse(value); + } + + serialize(value: TParsed): TSerialized { + return this.options.serialize(value); + } + + coerceToParsed(value: TSerialized | TParsed): TParsed { + return this.is(value) ? value : this.parse(value); + } + + coerceToSerialized(value: TSerialized | TParsed): TSerialized { + return this.is(value) ? this.serialize(value) : value; + } + + is(value: TSerialized | TParsed): value is TParsed { + if (this.options.is) { + return this.options.is(value); + } + + return typeof value === "object" && value !== null; + } +} diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts index 38dec61e4b0..aef191a5b1e 100644 --- a/src/cache/core/__tests__/cache.ts +++ b/src/cache/core/__tests__/cache.ts @@ -4,7 +4,7 @@ import { gql } from "graphql-tag"; import type { OperationVariables, Unmasked } from "@apollo/client"; import type { Cache } from "@apollo/client/cache"; -import { ApolloCache } from "@apollo/client/cache"; +import { ApolloCache, Scalar } from "@apollo/client/cache"; import type { Reference } from "../../../utilities/graphql/storeUtils.js"; @@ -358,6 +358,32 @@ describe("abstract cache", () => { }); }); }); + + describe("getScalar", () => { + it("returns undefined by default", () => { + const cache = new TestCache(); + + expect(cache.getScalar("DateTime")).toBeUndefined(); + }); + + it("returns the scalar from a cache that overrides getScalar", () => { + const dateTime = new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + is: (value) => value instanceof Date, + }); + + class ScalarCache extends TestCache { + getScalar( + key: TKey + ): ApolloCache.GetScalarType | undefined { + return dateTime as ApolloCache.GetScalarType; + } + } + + expect(new ScalarCache().getScalar("DateTime")).toBe(dateTime); + }); + }); }); describe.skip("Cache type tests", () => { diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 47a1ae1a917..c7e4cea75e6 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -46,6 +46,7 @@ import { invariant } from "@apollo/client/utilities/invariant"; import { defaultCacheSizes } from "../../utilities/caching/sizes.js"; +import type { Scalar } from "./Scalar.js"; import type { Cache } from "./types/Cache.js"; import type { MissingTree } from "./types/common.js"; @@ -156,6 +157,16 @@ export declare namespace ApolloCache { */ getCurrentResult: () => ApolloCache.WatchFragmentResult; } + + // Registration type for custom scalars + export interface Scalars {} + + export type GetScalarType = + ApolloCache.Scalars[TKey] extends ( + { serialized: infer TSerialized; parsed: infer TParsed } + ) ? + Scalar + : never; } export abstract class ApolloCache { @@ -241,6 +252,43 @@ export abstract class ApolloCache { return null; } + // Custom scalars API + + public getScalar( + key: TKey + ): ApolloCache.GetScalarType | undefined { + return; + } + + /** + * Serializes scalar values in the variables object + */ + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer + ): TVariables; + + /** + * {@inheritDoc @apollo/client/cache!ApolloCache#serializeVariables:member(1)} + */ + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer | undefined + ): TVariables | undefined; + + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer | undefined + ): TVariables | undefined { + return variables; + } + // Local state API /** diff --git a/src/cache/index.ts b/src/cache/index.ts index 20ac58494a2..4c188f93d45 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -4,6 +4,7 @@ export type { WatchFragmentResult, } from "./deprecated.js"; export { ApolloCache } from "./core/cache.js"; +export { Scalar } from "./core/Scalar.js"; export type { Cache } from "./core/types/Cache.js"; export type { IgnoreModifier, @@ -51,6 +52,8 @@ export type { IdGetter, IdGetterObj, InMemoryCacheConfig, + InputObjectConfig, + InputObjectsOption, MergeInfo, MergeTree, NormalizedCache, diff --git a/src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts b/src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts new file mode 100644 index 00000000000..3f54a11edcf --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts @@ -0,0 +1,212 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: "2026-01-01", + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: false, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).toStrictEqualTyped({ + complete: true, + missing: undefined, + result: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar variables used in directive arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Query: { + fields: { + event: { + keyArgs: ["@on", ["date"]], + }, + }, + }, + }, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: "2026-01-01", + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: false, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).toStrictEqualTyped({ + complete: true, + missing: undefined, + result: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar fields in input object variables", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + filter: { + date: "2026-01-01", + }, + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.diff({ + query, + optimistic: false, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).toStrictEqualTyped({ + complete: true, + missing: undefined, + result: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("returns parsed custom scalar fields", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }); + const query = gql` + query Event { + event { + startDate + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + startDate: "2026-01-01", + }, + }, + }); + + expect(cache.diff({ query, optimistic: false })).toStrictEqualTyped({ + complete: true, + missing: undefined, + result: { + event: { + __typename: "Event", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); diff --git a/src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts b/src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts new file mode 100644 index 00000000000..fbd31b787a5 --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts @@ -0,0 +1,42 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("returns parsed custom scalar fields", () => { + const fragment = gql` + fragment EventFields on Event { + id + startDate + } + `; + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }); + + expect( + cache.readFragment({ + fragment, + id: "Event:1", + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); +}); diff --git a/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts b/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts new file mode 100644 index 00000000000..fbd346b6c26 --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts @@ -0,0 +1,193 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: "2026-01-01", + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.readQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).toStrictEqualTyped({ + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }); +}); + +test("serializes scalar variables used in directive arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Query: { + fields: { + event: { + keyArgs: ["@on", ["date"]], + }, + }, + }, + }, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: "2026-01-01", + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.readQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).toStrictEqualTyped({ + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }); +}); + +test("serializes scalar fields in input object variables", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + filter: { + date: "2026-01-01", + }, + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect( + cache.readQuery({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).toStrictEqualTyped({ + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }); +}); + +test("returns parsed custom scalar fields", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }); + const query = gql` + query Event { + event { + startDate + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + startDate: "2026-01-01", + }, + }, + }); + + expect(cache.readQuery({ query })).toStrictEqualTyped({ + event: { + __typename: "Event", + startDate: new Date(2026, 0, 1), + }, + }); +}); diff --git a/src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts b/src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts new file mode 100644 index 00000000000..7791dfbaf3c --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts @@ -0,0 +1,138 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event({"date":"2026-01-01"})': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar variables used in directive arguments", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Query: { + fields: { + event: { + keyArgs: ["@on", ["date"]], + }, + }, + }, + }, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event:{"@on":{"date":"2026-01-01"}}': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar fields in input object variables", () => { + const cache = new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + cache.writeQuery({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(cache.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event({"filter":{"date":"2026-01-01"}})': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); diff --git a/src/cache/inmemory/__tests__/scalars.ts b/src/cache/inmemory/__tests__/scalars.ts new file mode 100644 index 00000000000..113f5c71673 --- /dev/null +++ b/src/cache/inmemory/__tests__/scalars.ts @@ -0,0 +1,4113 @@ +import { GraphQLScalarType, version as graphqlVersion } from "graphql"; + +import type { TypedDocumentNode } from "@apollo/client"; +import { gql } from "@apollo/client"; +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { + dateTimeScalar, + jsonObjectScalar, + ObservableStream, + priceScalar, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const IS_GRAPHQL_17 = graphqlVersion.startsWith("17"); + +test("creates a scalar from a GraphQLScalarType", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "DateTime", + serialize: (value) => { + if (!(value instanceof Date)) { + throw new TypeError("Expected a Date"); + } + + return value.toISOString(); + }, + parseValue: (value) => { + if (typeof value !== "string") { + throw new TypeError("Expected a string"); + } + + return new Date(value); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar); + + expect(scalar.parse("2026-01-01T00:00:00.000Z")).toEqual( + new Date("2026-01-01T00:00:00.000Z") + ); + expect(scalar.serialize(new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2026-01-01T00:00:00.000Z" + ); +}); + +if (IS_GRAPHQL_17) { + test("creates a scalar from a GraphQLScalarType using GraphQL 17 initializer", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "DateTime", + coerceOutputValue: (value) => { + if (!(value instanceof Date)) { + throw new TypeError("Expected a Date"); + } + + return value.toISOString(); + }, + coerceInputValue: (value) => { + if (typeof value !== "string") { + throw new TypeError("Expected a string"); + } + + return new Date(value); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar); + + expect(scalar.parse("2026-01-01T00:00:00.000Z")).toEqual( + new Date("2026-01-01T00:00:00.000Z") + ); + expect(scalar.serialize(new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2026-01-01T00:00:00.000Z" + ); + }); +} + +test("uses the configured type guard when coercing values", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "Price", + serialize: (value) => { + if (typeof value !== "number") { + throw new TypeError("Expected a number"); + } + + return value.toFixed(2); + }, + parseValue: (value) => { + if (typeof value !== "string") { + throw new TypeError("Expected a string"); + } + + return Number(value); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar, { + is: (value) => typeof value === "number", + }); + + expect(scalar.coerceToParsed("12.34")).toBe(12.34); + expect(scalar.coerceToParsed(12.34)).toBe(12.34); + expect(scalar.coerceToSerialized(12.34)).toBe("12.34"); + expect(scalar.coerceToSerialized("12.34")).toBe("12.34"); +}); + +if (IS_GRAPHQL_17) { + test("uses the configured type guard with a GraphQL 17 initializer", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "Price", + coerceOutputValue: (value) => { + if (typeof value !== "number") { + throw new TypeError("Expected a number"); + } + + return value.toFixed(2); + }, + coerceInputValue: (value) => { + if (typeof value !== "string") { + throw new TypeError("Expected a string"); + } + + return Number(value); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar, { + is: (value) => typeof value === "number", + }); + + expect(scalar.coerceToParsed("12.34")).toBe(12.34); + expect(scalar.coerceToParsed(12.34)).toBe(12.34); + expect(scalar.coerceToSerialized(12.34)).toBe("12.34"); + expect(scalar.coerceToSerialized("12.34")).toBe("12.34"); + }); +} + +test("preserves errors thrown by the GraphQLScalarType", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "DateTime", + serialize: () => { + throw new TypeError("Unable to serialize DateTime"); + }, + parseValue: () => { + throw new TypeError("Unable to parse DateTime"); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar); + + expect(() => scalar.parse("invalid")).toThrow("Unable to parse DateTime"); + expect(() => scalar.serialize(new Date())).toThrow( + "Unable to serialize DateTime" + ); +}); + +if (IS_GRAPHQL_17) { + test("preserves errors thrown by a GraphQL 17 initializer", () => { + const graphQLScalar = new GraphQLScalarType({ + name: "DateTime", + coerceOutputValue: () => { + throw new TypeError("Unable to serialize DateTime"); + }, + coerceInputValue: () => { + throw new TypeError("Unable to parse DateTime"); + }, + }); + + const scalar = Scalar.fromGraphQLScalarType(graphQLScalar); + + expect(() => scalar.parse("invalid")).toThrow("Unable to parse DateTime"); + expect(() => scalar.serialize(new Date())).toThrow( + "Unable to serialize DateTime" + ); + }); +} + +test("getScalar returns a scalar object for a configured scalar", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expect(cache.getScalar("DateTime")).toBeDefined(); +}); + +test("getScalar returns undefined for an unconfigured scalar", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + expect(cache.getScalar("Unconfigured")).toBeUndefined(); +}); + +test("serialize uses the configured serialize function", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + const scalar = cache.getScalar("DateTime")!; + + expect(scalar.serialize(new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2026-01-01T00:00:00.000Z" + ); +}); + +test("parse uses the configured parse function", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + const scalar = cache.getScalar("DateTime")!; + + expect(scalar.parse("2026-01-01T00:00:00.000Z")).toEqual( + new Date("2026-01-01T00:00:00.000Z") + ); +}); + +test("is defaults to a non-null object check when not configured", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + }), + }, + }); + + const scalar = cache.getScalar("DateTime")!; + + expect(scalar.is(new Date("2026-01-01T00:00:00.000Z"))).toBe(true); + expect(scalar.is("2026-01-01T00:00:00.000Z")).toBe(false); +}); + +test("is uses the configured type guard when configured", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + is: (value) => value instanceof Date && !Number.isNaN(value.getTime()), + }), + }, + }); + + const scalar = cache.getScalar("DateTime")!; + + expect(scalar.is(new Date("2026-01-01T00:00:00.000Z"))).toBe(true); + expect(scalar.is(new Date("invalid"))).toBe(false); +}); + +test("stores parsed scalar value in the cache when writing via cache.writeQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses serialized scalar value when writing via cache.writeQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores a parsed scalar value on a custom root type when writing via cache.writeQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + now + } + `; + + cache.writeQuery({ + query, + data: { + now: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "RootQuery", + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("leaves parsed value unchanged when no scalar policy is configured", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores parsed scalar value in the cache when the field has an alias", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + __typename + id + start: startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + start: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores parsed scalar value in the cache when the field has arguments with variables", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query ($timezone: String!) { + event { + id + startTime(timezone: $timezone) + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + variables: { timezone: "UTC" }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + 'startTime({"timezone":"UTC"})': new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores parsed scalar value in the cache when the field has arguments with literal value", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime(timezone: "UTC") + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + 'startTime({"timezone":"UTC"})': new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores each element as a parsed value when writing an array of scalar values", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + meetingTimes: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + meetingTimes + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }, + }); +}); + +test("parses each serialized element when writing an array of scalar values", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + meetingTimes: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + meetingTimes + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + meetingTimes: ["2026-01-01T09:00:00.000Z", "2026-01-02T09:00:00.000Z"], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }, + }); +}); + +test("stores each leaf element as a parsed value when writing a 2D array of scalar values", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + availabilitySlots: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + availabilitySlots + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + schedule: { + __typename: "Schedule", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }, + }); +}); + +test("stores null as-is when null is written to a scalar field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + endTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + endTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + endTime: null, + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + endTime: null, + }, + }); +}); + +test("stores object-based parsed scalar values (e.g. Map) when writing", () => { + const cache = new InMemoryCache({ + scalars: { JSONObject: jsonObjectScalar }, + typePolicies: { + Product: { + fields: { + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query { + product { + id + metadata + } + } + `; + + cache.writeQuery({ + query, + data: { + product: { + __typename: "Product", + id: "1", + metadata: new Map([ + ["color", "red"], + ["size", "large"], + ]), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", product: { __ref: "Product:1" } }, + "Product:1": { + __typename: "Product", + id: "1", + metadata: new Map([ + ["color", "red"], + ["size", "large"], + ]), + }, + }); +}); + +test("stores parsed scalar value in the cache when writing via cache.writeFragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const fragment = gql` + fragment EventFields on Event { + id + startTime + } + `; + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + + expect(rawCacheData(cache)).toEqual({ + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores parsed scalar value in the cache when overwriting an existing field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }); +}); + +test("stores parsed scalar value in the cache when a merge function is also configured on the field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { + scalar: "DateTime", + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + event: { __ref: "Event:1" }, + }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores the parsed value returned by a merge function in the cache", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { + scalar: "DateTime", + merge: (_, incoming) => new Date(incoming), + }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + event: { __ref: "Event:1" }, + }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("stores each element as a parsed value when writing an array of scalar values with a merge function", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + meetingTimes: { + scalar: "DateTime", + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + meetingTimes + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }, + }); +}); + +test("stores each leaf element as a parsed value when writing a 2D array of scalar values with a merge function", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + availabilitySlots: { + scalar: "DateTime", + merge: (_, incoming) => incoming, + }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + availabilitySlots + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + schedule: { + __typename: "Schedule", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }, + }); +}); + +test("stores parsed scalar values across a complex nested write", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + JSONObject: jsonObjectScalar, + }, + possibleTypes: { + Schedulable: ["Session", "Workshop"], + }, + typePolicies: { + Conference: { + fields: { + startDate: { scalar: "DateTime" }, + endDate: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + Schedule: { + fields: { + timeSlots: { scalar: "DateTime" }, + }, + }, + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + Session: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + Workshop: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query { + conference { + id + name + startDate + endDate + ticketPrice + schedule { + timeSlots + } + speakers { + id + name + availableTimes + } + scheduledItems { + __typename + id + startTime + metadata + } + } + } + `; + + cache.writeQuery({ + query, + data: { + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: null, + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + timeSlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [new Date("2026-09-15T14:00:00.000Z")], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: [new Date("2026-09-15T10:00:00.000Z"), null], + }, + ], + scheduledItems: [ + { + __typename: "Session", + id: "session-1", + startTime: new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + { + __typename: "Workshop", + id: "workshop-1", + startTime: new Date("2026-09-15T14:00:00.000Z"), + metadata: new Map([["venue", "The Workshop Building"]]), + }, + ], + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + conference: { __ref: "Conference:conf-1" }, + }, + "Conference:conf-1": { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: null, + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + timeSlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [new Date("2026-09-15T14:00:00.000Z")], + ], + }, + scheduledItems: [ + { __ref: "Session:session-1" }, + { __ref: "Workshop:workshop-1" }, + ], + speakers: [ + { __ref: "Speaker:speaker-1" }, + { __ref: "Speaker:speaker-2" }, + ], + }, + "Speaker:speaker-1": { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + "Speaker:speaker-2": { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: [new Date("2026-09-15T10:00:00.000Z"), null], + }, + "Session:session-1": { + __typename: "Session", + id: "session-1", + startTime: new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + "Workshop:workshop-1": { + __typename: "Workshop", + id: "workshop-1", + startTime: new Date("2026-09-15T14:00:00.000Z"), + metadata: new Map([["venue", "The Workshop Building"]]), + }, + }); +}); + +test("stores parsed scalar value in the cache when modifying via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: () => new Date("2026-06-15T14:30:00.000Z"), + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }); +}); + +test("parses serialized scalar value when modifying via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: () => "2026-06-15T14:30:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }); +}); + +test("cache.modify preserves referential identity for deeply equal parsed scalar values", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + const existingStartTime = rawCacheData(cache)["Event:1"]!.startTime; + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: () => "2026-01-01T00:00:00.000Z", + }, + }); + + expect(rawCacheData(cache)["Event:1"]!.startTime).toBe(existingStartTime); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: () => new Date("2026-01-01T00:00:00.000Z"), + }, + }); + + expect(rawCacheData(cache)["Event:1"]!.startTime).toBe(existingStartTime); +}); + +test("cache.modify preserves references when scalar values are already parsed", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + keyFields: false, + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + name + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + name: "Opening keynote", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + const replacementEvent = { + __typename: "Event", + name: "Closing keynote", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }; + + cache.modify({ + id: "ROOT_QUERY", + fields: { + event: () => replacementEvent, + }, + }); + + const modifiedEvent = rawCacheData(cache).ROOT_QUERY!.event; + + expect(modifiedEvent).toBe(replacementEvent); + expect((modifiedEvent as any).startTime).toBe(replacementEvent.startTime); +}); + +test("leaves parsed value unchanged when modifying via cache.modify with no scalar policy configured", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: () => new Date("2026-06-15T14:30:00.000Z"), + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }); +}); + +test("parses a scalar field on an implicit root type when modifying via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Query: { + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + + cache.modify({ + id: "ROOT_QUERY", + fields: { + now: () => "2026-06-15T14:30:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + now: new Date("2026-06-15T14:30:00.000Z"), + }, + }); +}); + +test("stores each element as a parsed value when modifying an array of scalar values via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + speaker { + id + availableTimes + } + } + `; + + cache.writeQuery({ + query, + data: { + speaker: { + __typename: "Speaker", + id: "1", + availableTimes: ["2026-01-01T09:00:00.000Z"], + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Speaker", id: "1" }), + fields: { + availableTimes: () => [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", speaker: { __ref: "Speaker:1" } }, + "Speaker:1": { + __typename: "Speaker", + id: "1", + availableTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }); +}); + +test("stores each leaf element as a parsed value when modifying a 2D array of scalar values via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Speaker: { + fields: { + availabilitySlots: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + speaker { + id + availabilitySlots + } + } + `; + + cache.writeQuery({ + query, + data: { + speaker: { + __typename: "Speaker", + id: "1", + availabilitySlots: [["2026-01-01T09:00:00.000Z"]], + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Speaker", id: "1" }), + fields: { + availabilitySlots: () => [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", speaker: { __ref: "Speaker:1" } }, + "Speaker:1": { + __typename: "Speaker", + id: "1", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }); +}); + +test("stores null as-is when null is returned by a modifier for a scalar field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + endTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + endTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + endTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + endTime: () => null, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + endTime: null, + }, + }); +}); + +test("deletes a scalar field when returning DELETE from cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Event", id: "1" }), + fields: { + startTime: (_, { DELETE }) => DELETE, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + }, + }); +}); + +test("stores object-based parsed scalar values when modifying via cache.modify", () => { + const cache = new InMemoryCache({ + scalars: { JSONObject: jsonObjectScalar }, + typePolicies: { + Product: { + fields: { + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query { + product { + id + metadata + } + } + `; + + cache.writeQuery({ + query, + data: { + product: { + __typename: "Product", + id: "1", + metadata: { color: "red", size: "large" }, + }, + }, + }); + + cache.modify({ + id: cache.identify({ __typename: "Product", id: "1" }), + fields: { + metadata: () => + new Map([ + ["color", "blue"], + ["size", "medium"], + ]), + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", product: { __ref: "Product:1" } }, + "Product:1": { + __typename: "Product", + id: "1", + metadata: new Map([ + ["color", "blue"], + ["size", "medium"], + ]), + }, + }); +}); + +test("cache.extract() serializes all stored parsed scalar values", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + JSONObject: jsonObjectScalar, + }, + possibleTypes: { + Schedulable: ["Session", "Workshop"], + }, + typePolicies: { + Conference: { + fields: { + startDate: { scalar: "DateTime" }, + endDate: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + Schedule: { + fields: { + meetingTimes: { scalar: "DateTime" }, + availabilitySlots: { scalar: "DateTime" }, + }, + }, + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + Session: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + Workshop: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query ($timezone: String) { + conference { + id + name + startDate + endDate + ticketPrice + schedule { + meetingTimes + availabilitySlots + } + speakers { + id + name + availableTimes(timezone: "UTC") + } + scheduledItems { + __typename + id + startTime(timezone: $timezone) + metadata + } + } + } + `; + + cache.writeQuery({ + query, + variables: { timezone: "UTC" }, + data: { + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: null, + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + availabilitySlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [new Date("2026-09-15T14:00:00.000Z")], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: [new Date("2026-09-15T10:00:00.000Z"), null], + }, + ], + scheduledItems: [ + { + __typename: "Session", + id: "session-1", + startTime: new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + { + __typename: "Workshop", + id: "workshop-1", + startTime: new Date("2026-09-15T14:00:00.000Z"), + metadata: new Map([["venue", "The Workshop Building"]]), + }, + ], + }, + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + conference: { __ref: "Conference:conf-1" }, + }, + "Conference:conf-1": { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: "2026-09-15T09:00:00.000Z", + endDate: null, + ticketPrice: 19900, + schedule: { + __typename: "Schedule", + meetingTimes: ["2026-09-15T09:00:00.000Z", "2026-09-15T14:00:00.000Z"], + availabilitySlots: [ + ["2026-09-15T09:00:00.000Z", "2026-09-15T10:00:00.000Z"], + ["2026-09-15T14:00:00.000Z"], + ], + }, + scheduledItems: [ + { __ref: "Session:session-1" }, + { __ref: "Workshop:workshop-1" }, + ], + speakers: [ + { __ref: "Speaker:speaker-1" }, + { __ref: "Speaker:speaker-2" }, + ], + }, + "Speaker:speaker-1": { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + 'availableTimes({"timezone":"UTC"})': [ + "2026-09-15T09:00:00.000Z", + "2026-09-15T14:00:00.000Z", + ], + }, + "Speaker:speaker-2": { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + 'availableTimes({"timezone":"UTC"})': ["2026-09-15T10:00:00.000Z", null], + }, + "Session:session-1": { + __typename: "Session", + id: "session-1", + 'startTime({"timezone":"UTC"})': "2026-09-15T09:00:00.000Z", + metadata: { dress: "casual" }, + }, + "Workshop:workshop-1": { + __typename: "Workshop", + id: "workshop-1", + 'startTime({"timezone":"UTC"})': "2026-09-15T14:00:00.000Z", + metadata: { venue: "The Workshop Building" }, + }, + }); +}); + +test("cache.restore() parses all serialized scalar values before storing them", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + JSONObject: jsonObjectScalar, + }, + possibleTypes: { + Schedulable: ["Session", "Workshop"], + }, + typePolicies: { + Conference: { + fields: { + startDate: { scalar: "DateTime" }, + endDate: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + Schedule: { + fields: { + meetingTimes: { scalar: "DateTime" }, + availabilitySlots: { scalar: "DateTime" }, + }, + }, + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + Session: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + Workshop: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + __typename: "Query", + conference: { __ref: "Conference:conf-1" }, + }, + "Conference:conf-1": { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: "2026-09-15T09:00:00.000Z", + endDate: null, + ticketPrice: 19900, + schedule: { + __typename: "Schedule", + meetingTimes: ["2026-09-15T09:00:00.000Z", "2026-09-15T14:00:00.000Z"], + availabilitySlots: [ + ["2026-09-15T09:00:00.000Z", "2026-09-15T10:00:00.000Z"], + ["2026-09-15T14:00:00.000Z"], + ], + }, + scheduledItems: [ + { __ref: "Session:session-1" }, + { __ref: "Workshop:workshop-1" }, + ], + speakers: [ + { __ref: "Speaker:speaker-1" }, + { __ref: "Speaker:speaker-2" }, + ], + }, + "Speaker:speaker-1": { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + 'availableTimes({"timezone":"UTC"})': [ + "2026-09-15T09:00:00.000Z", + "2026-09-15T14:00:00.000Z", + ], + }, + "Speaker:speaker-2": { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + 'availableTimes({"timezone":"UTC"})': ["2026-09-15T10:00:00.000Z", null], + }, + "Session:session-1": { + __typename: "Session", + id: "session-1", + 'startTime({"timezone":"UTC"})': "2026-09-15T09:00:00.000Z", + metadata: { dress: "casual" }, + }, + "Workshop:workshop-1": { + __typename: "Workshop", + id: "workshop-1", + 'startTime({"timezone":"UTC"})': "2026-09-15T14:00:00.000Z", + metadata: { venue: "The Workshop Building" }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + conference: { __ref: "Conference:conf-1" }, + }, + "Conference:conf-1": { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: null, + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + availabilitySlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [new Date("2026-09-15T14:00:00.000Z")], + ], + }, + scheduledItems: [ + { __ref: "Session:session-1" }, + { __ref: "Workshop:workshop-1" }, + ], + speakers: [ + { __ref: "Speaker:speaker-1" }, + { __ref: "Speaker:speaker-2" }, + ], + }, + "Speaker:speaker-1": { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + 'availableTimes({"timezone":"UTC"})': [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + "Speaker:speaker-2": { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + 'availableTimes({"timezone":"UTC"})': [ + new Date("2026-09-15T10:00:00.000Z"), + null, + ], + }, + "Session:session-1": { + __typename: "Session", + id: "session-1", + 'startTime({"timezone":"UTC"})': new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + "Workshop:workshop-1": { + __typename: "Workshop", + id: "workshop-1", + 'startTime({"timezone":"UTC"})': new Date("2026-09-15T14:00:00.000Z"), + metadata: new Map([["venue", "The Workshop Building"]]), + }, + }); +}); + +test("cache.restore() preserves references when scalar values are already parsed", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const event = { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }; + + cache.restore({ + "Event:1": event, + }); + + const restoredEvent = rawCacheData(cache)["Event:1"]; + + expect(restoredEvent).toBe(event); + expect(restoredEvent!.startTime).toBe(event.startTime); +}); + +test("cache.restore() leaves values as-is when no scalar policy is configured for the field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + }); + + cache.restore({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); +}); + +test("cache.restore() parses scalar fields on root objects with an implicit typename", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Query: { + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + now: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("cache.restore() parses scalar fields on a custom root type with an implicit typename", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + now: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("cache.restore() parses scalar fields in arrays of non-normalized objects", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + keyFields: false, + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + __typename: "Query", + events: [ + { + __typename: "Event", + startTime: "2026-01-01T00:00:00.000Z", + }, + { + __typename: "Event", + startTime: "2026-02-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + events: [ + { + __typename: "Event", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + { + __typename: "Event", + startTime: new Date("2026-02-01T00:00:00.000Z"), + }, + ], + }, + }); +}); + +test("cache.extract() returns the parsed value as-is when no scalar policy is configured for the field", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("cache.extract() serializes scalar fields on root objects with an implicit typename", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Query: { + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + cache.restore({ + ROOT_QUERY: { + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + now: "2026-01-01T00:00:00.000Z", + }, + }); +}); + +test("cache.extract() serializes scalar fields on a custom root type with an implicit typename", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + now + } + `; + + cache.writeQuery({ + query, + data: { + now: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "RootQuery", + now: "2026-01-01T00:00:00.000Z", + }, + }); +}); + +test("cache.extract() serializes scalar fields in arrays of non-normalized objects written with cache.writeQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + keyFields: false, + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + events { + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + events: [ + { + __typename: "Event", + startTime: "2026-01-01T00:00:00.000Z", + }, + { + __typename: "Event", + startTime: "2026-02-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + __typename: "Query", + events: [ + { + __typename: "Event", + startTime: "2026-01-01T00:00:00.000Z", + }, + { + __typename: "Event", + startTime: "2026-02-01T00:00:00.000Z", + }, + ], + }, + }); +}); + +test("cache.extract() serializes scalar fields in arrays of non-normalized objects written with cache.writeFragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + keyFields: false, + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const fragment = gql` + fragment ScheduleFields on Schedule { + id + events { + startTime + } + } + `; + + cache.writeFragment({ + fragment, + data: { + __typename: "Schedule", + id: "1", + events: [ + { + __typename: "Event", + startTime: "2026-01-01T00:00:00.000Z", + }, + { + __typename: "Event", + startTime: "2026-02-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(cache.extract()).toEqual({ + "Schedule:1": { + __typename: "Schedule", + id: "1", + events: [ + { + __typename: "Event", + startTime: "2026-01-01T00:00:00.000Z", + }, + { + __typename: "Event", + startTime: "2026-02-01T00:00:00.000Z", + }, + ], + }, + __META: { + extraRootIds: ["Schedule:1"], + }, + }); +}); + +test("cache.extract(true) serializes scalar values from the optimistic layer", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }, + }); + + cache.recordOptimisticTransaction((proxy) => { + proxy.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + }, + }); + }, "optimistic-update"); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(cache.extract(true)).toEqual({ + ROOT_QUERY: { __typename: "Query", event: { __ref: "Event:1" } }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: "2026-06-15T14:30:00.000Z", + }, + }); +}); + +test("parses scalar value when reading a field via cache.readQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses a scalar field on a custom root type via cache.readQuery", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + now + } + `; + + cache.writeQuery({ + query, + data: { + now: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + now: new Date("2026-01-01T00:00:00.000Z"), + }); +}); + +test("parses scalar value when reading a field via cache.readFragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const fragment = gql` + fragment EventFields on Event { + id + startTime + } + `; + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); + + expect( + cache.readFragment({ + id: cache.identify({ __typename: "Event", id: "1" })!, + fragment, + }) + ).toEqual({ + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }); +}); + +test("parses a scalar field on a custom root type via cache.readFragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + now: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + now + } + `; + const fragment = gql` + fragment RootQueryFields on RootQuery { + now + } + `; + + cache.writeQuery({ + query, + data: { + now: "2026-01-01T00:00:00.000Z", + }, + }); + + expect( + cache.readFragment({ + id: "ROOT_QUERY", + fragment, + }) + ).toEqual({ + now: new Date("2026-01-01T00:00:00.000Z"), + }); +}); + +test("parses scalar value when the field has literal arguments", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime(timezone: "UTC") + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses scalar value when the field has arguments with variables", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query ($timezone: String!) { + event { + id + startTime(timezone: $timezone) + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + variables: { timezone: "UTC" }, + }); + + expect(cache.readQuery({ query, variables: { timezone: "UTC" } })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses each element when the scalar field contains an array of values", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + meetingTimes: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + meetingTimes + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + meetingTimes: ["2026-01-01T09:00:00.000Z", "2026-01-02T09:00:00.000Z"], + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + schedule: { + __typename: "Schedule", + meetingTimes: [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-02T09:00:00.000Z"), + ], + }, + }); +}); + +test("parses each leaf element when the scalar field contains a 2D array", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Schedule: { + fields: { + availabilitySlots: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + schedule { + availabilitySlots + } + } + `; + + cache.writeQuery({ + query, + data: { + schedule: { + __typename: "Schedule", + availabilitySlots: [ + ["2026-01-01T09:00:00.000Z", "2026-01-01T10:00:00.000Z"], + ["2026-01-02T14:00:00.000Z"], + ], + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + schedule: { + __typename: "Schedule", + availabilitySlots: [ + [ + new Date("2026-01-01T09:00:00.000Z"), + new Date("2026-01-01T10:00:00.000Z"), + ], + [new Date("2026-01-02T14:00:00.000Z")], + ], + }, + }); +}); + +test("returns null as-is when null is stored in a scalar field position", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + endTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + endTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + endTime: null, + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + endTime: null, + }, + }); +}); + +test("parses object-based scalar values (e.g. JSON) when reading from cache", () => { + const cache = new InMemoryCache({ + scalars: { JSONObject: jsonObjectScalar }, + typePolicies: { + Product: { + fields: { + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query { + product { + id + metadata + } + } + `; + + cache.writeQuery({ + query, + data: { + product: { + __typename: "Product", + id: "1", + metadata: { color: "red", size: "large" }, + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + product: { + __typename: "Product", + id: "1", + metadata: new Map([ + ["color", "red"], + ["size", "large"], + ]), + }, + }); +}); + +test("parses primitive-to-primitive scalar values when reading from cache", () => { + const cache = new InMemoryCache({ + scalars: { Price: priceScalar }, + typePolicies: { + Product: { + fields: { + price: { scalar: "Price" }, + }, + }, + }, + }); + + const query = gql` + query { + product { + id + price + } + } + `; + + cache.writeQuery({ + query, + data: { + product: { + __typename: "Product", + id: "1", + price: 1099, + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + product: { + __typename: "Product", + id: "1", + price: "10.99", + }, + }); +}); + +test("parses scalar fields within each object in an array", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + events { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + events: [ + { __typename: "Event", id: "1", startTime: "2026-01-01T09:00:00.000Z" }, + { __typename: "Event", id: "2", startTime: "2026-01-02T09:00:00.000Z" }, + ], + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + events: [ + { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T09:00:00.000Z"), + }, + { + __typename: "Event", + id: "2", + startTime: new Date("2026-01-02T09:00:00.000Z"), + }, + ], + }); +}); + +test("parses multiple scalar fields on the same object", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar, Price: priceScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + endTime: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + endTime + ticketPrice + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T09:00:00.000Z", + endTime: "2026-01-01T10:00:00.000Z", + ticketPrice: 2099, + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T09:00:00.000Z"), + endTime: new Date("2026-01-01T10:00:00.000Z"), + ticketPrice: "20.99", + }, + }); +}); + +test("parses scalar values when the field is selected via a named fragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + ...EventFields + } + } + + fragment EventFields on Event { + id + startTime + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses scalar values when the field is selected via an inline fragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + ... @defer { + id + startTime + } + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses scalar values on the matching member types of a union", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { startTime: { scalar: "DateTime" } }, + }, + Appointment: { + fields: { startTime: { scalar: "DateTime" } }, + }, + }, + }); + + const query = gql` + query { + searchResults { + __typename + ... on Event { + id + startTime + } + ... on Appointment { + id + startTime + } + } + } + `; + + cache.writeQuery({ + query, + data: { + searchResults: [ + { + __typename: "Event", + id: "1", + startTime: "2026-01-01T09:00:00.000Z", + }, + { + __typename: "Appointment", + id: "2", + startTime: "2026-01-02T14:00:00.000Z", + }, + ], + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + searchResults: [ + { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T09:00:00.000Z"), + }, + { + __typename: "Appointment", + id: "2", + startTime: new Date("2026-01-02T14:00:00.000Z"), + }, + ], + }); +}); + +test("parses scalar values when fields are selected through an interface fragment", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + possibleTypes: { + Schedulable: ["Event", "Appointment"], + }, + typePolicies: { + Event: { + fields: { startTime: { scalar: "DateTime" } }, + }, + Appointment: { + fields: { startTime: { scalar: "DateTime" } }, + }, + }, + }); + + const query = gql` + query { + scheduledItems { + __typename + ...SchedulableFields + } + } + + fragment SchedulableFields on Schedulable { + id + startTime + } + `; + + cache.writeQuery({ + query, + data: { + scheduledItems: [ + { + __typename: "Event", + id: "1", + startTime: "2026-01-01T09:00:00.000Z", + }, + { + __typename: "Appointment", + id: "2", + startTime: "2026-01-02T14:00:00.000Z", + }, + ], + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + scheduledItems: [ + { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T09:00:00.000Z"), + }, + { + __typename: "Appointment", + id: "2", + startTime: new Date("2026-01-02T14:00:00.000Z"), + }, + ], + }); +}); + +test("returns the raw value unchanged when a scalar field policy names an unregistered scalar", () => { + using _ = spyOnConsole("warn"); + + const cache = new InMemoryCache({ + typePolicies: { + Event: { + fields: { + startTime: { + scalar: "Identity", + }, + }, + }, + }, + }); + + const query = gql` + query { + event { + __typename + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(console.warn).not.toHaveBeenCalled(); +}); + +test("parses scalars for fields with aliases", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + __typename + id + start: startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + start: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + start: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("parses scalar values across a complex nested query", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + JSONObject: jsonObjectScalar, + }, + possibleTypes: { + Schedulable: ["Session", "Workshop"], + }, + typePolicies: { + Conference: { + fields: { + startDate: { scalar: "DateTime" }, + endDate: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + Schedule: { + fields: { + timeSlots: { scalar: "DateTime" }, + }, + }, + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + Session: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + Workshop: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + VirtualPresenter: { + fields: { nextSession: { scalar: "DateTime" } }, + }, + InPersonPresenter: { + fields: { arrivalTime: { scalar: "DateTime" } }, + }, + }, + }); + + const query = gql` + query { + conference { + id + name + startDate + endDate + ticketPrice + schedule { + timeSlots + } + ...SpeakerListFields + scheduledItems { + __typename + ...SchedulableFields + } + presenters { + __typename + ... on VirtualPresenter { + id + name + nextSession + } + ... on InPersonPresenter { + id + name + arrivalTime + } + } + } + } + + fragment SpeakerListFields on Conference { + speakers { + id + name + availableTimes + } + } + + fragment SchedulableFields on Schedulable { + id + startTime + metadata + } + `; + + cache.writeQuery({ + query, + data: { + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: "2026-09-15T09:00:00.000Z", + endDate: null, + ticketPrice: 19900, + schedule: { + __typename: "Schedule", + timeSlots: [ + ["2026-09-15T09:00:00.000Z", "2026-09-15T10:00:00.000Z"], + ["2026-09-15T14:00:00.000Z", "2026-09-15T15:00:00.000Z"], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + "2026-09-15T09:00:00.000Z", + "2026-09-15T14:00:00.000Z", + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + // null is valid in a scalar array + availableTimes: ["2026-09-15T10:00:00.000Z", null], + }, + ], + scheduledItems: [ + { + __typename: "Session", + id: "session-1", + startTime: "2026-09-15T09:00:00.000Z", + metadata: { dress: "casual" }, + }, + { + __typename: "Workshop", + id: "workshop-1", + startTime: "2026-09-15T14:00:00.000Z", + metadata: { venue: "The Workshop Building" }, + }, + ], + presenters: [ + { + __typename: "VirtualPresenter", + id: "vp-1", + name: "Charlie", + nextSession: "2026-09-15T09:00:00.000Z", + }, + { + __typename: "InPersonPresenter", + id: "ip-1", + name: "Diana", + arrivalTime: "2026-09-14T18:00:00.000Z", + }, + ], + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: null, + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + timeSlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [ + new Date("2026-09-15T14:00:00.000Z"), + new Date("2026-09-15T15:00:00.000Z"), + ], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: [new Date("2026-09-15T10:00:00.000Z"), null], + }, + ], + scheduledItems: [ + { + __typename: "Session", + id: "session-1", + startTime: new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + { + __typename: "Workshop", + id: "workshop-1", + startTime: new Date("2026-09-15T14:00:00.000Z"), + metadata: new Map([["venue", "The Workshop Building"]]), + }, + ], + presenters: [ + { + __typename: "VirtualPresenter", + id: "vp-1", + name: "Charlie", + nextSession: new Date("2026-09-15T09:00:00.000Z"), + }, + { + __typename: "InPersonPresenter", + id: "ip-1", + name: "Diana", + arrivalTime: new Date("2026-09-14T18:00:00.000Z"), + }, + ], + }, + }); +}); + +test("parses scalar values on each emit from cache.watchFragment", async () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { scalar: "DateTime" }, + }, + }, + }, + }); + + const fragment = gql` + fragment EventFields on Event { + id + startTime + } + `; + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T09:00:00.000Z", + }, + }); + + using stream = new ObservableStream( + cache.watchFragment({ + fragment, + from: { __typename: "Event", id: "1" }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T09:00:00.000Z"), + }, + dataState: "complete", + complete: true, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startTime: "2026-06-15T14:30:00.000Z", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + __typename: "Event", + id: "1", + startTime: new Date("2026-06-15T14:30:00.000Z"), + }, + dataState: "complete", + complete: true, + }); + + cache.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startTime: "2026-12-31T23:59:59.000Z", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + __typename: "Event", + id: "1", + startTime: new Date("2026-12-31T23:59:59.000Z"), + }, + dataState: "complete", + complete: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("ignores scalar and emits a dev warning when a scalar option is set on a field with a selection set", () => { + using _ = spyOnConsole("warn"); + + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Query: { + fields: { + event: { scalar: "DateTime" }, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "The field policy for '%s' is configured as a '%s' scalar, but the field is not a scalar field because it contains a selection set. The field value remains unchanged.", + "Query.event", + "DateTime" + ); +}); + +test("deep merges scalar option with policies.addTypePolices", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + JSONObject: jsonObjectScalar, + }, + typePolicies: { + Conference: { + fields: { + startDate: { scalar: "DateTime" }, + endDate: { merge: (_, incoming) => incoming }, + }, + }, + Schedule: { + fields: { + timeSlots: { scalar: "Price" }, + }, + }, + Speaker: { + fields: { + availableTimes: { keyArgs: false }, + }, + }, + }, + }); + + cache.policies.addTypePolicies({ + Conference: { + fields: { + endDate: { scalar: "DateTime" }, + ticketPrice: { scalar: "Price" }, + }, + }, + Schedule: { + fields: { + timeSlots: { scalar: "DateTime" }, + }, + }, + Speaker: { + fields: { + availableTimes: { scalar: "DateTime" }, + }, + }, + Session: { + fields: { + startTime: { scalar: "DateTime" }, + metadata: { scalar: "JSONObject" }, + }, + }, + }); + + const query = gql` + query { + conference { + id + name + startDate + endDate + ticketPrice + schedule { + timeSlots + } + speakers { + id + name + availableTimes + } + sessions { + __typename + id + startTime + metadata + } + } + } + `; + + cache.writeQuery({ + query, + data: { + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: "2026-09-15T09:00:00.000Z", + endDate: "2026-09-15T11:00:00.000Z", + ticketPrice: 19900, + schedule: { + __typename: "Schedule", + timeSlots: [ + ["2026-09-15T09:00:00.000Z", "2026-09-15T10:00:00.000Z"], + ["2026-09-15T14:00:00.000Z", "2026-09-15T15:00:00.000Z"], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + "2026-09-15T09:00:00.000Z", + "2026-09-15T14:00:00.000Z", + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: ["2026-09-15T10:00:00.000Z", null], + }, + ], + sessions: [ + { + __typename: "Session", + id: "session-1", + startTime: "2026-09-15T09:00:00.000Z", + metadata: { dress: "casual" }, + }, + ], + }, + }, + }); + + expect(cache.readQuery({ query })).toEqual({ + conference: { + __typename: "Conference", + id: "conf-1", + name: "GraphQL Summit", + startDate: new Date("2026-09-15T09:00:00.000Z"), + endDate: new Date("2026-09-15T11:00:00.000Z"), + ticketPrice: "199.00", + schedule: { + __typename: "Schedule", + timeSlots: [ + [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T10:00:00.000Z"), + ], + [ + new Date("2026-09-15T14:00:00.000Z"), + new Date("2026-09-15T15:00:00.000Z"), + ], + ], + }, + speakers: [ + { + __typename: "Speaker", + id: "speaker-1", + name: "Alice", + availableTimes: [ + new Date("2026-09-15T09:00:00.000Z"), + new Date("2026-09-15T14:00:00.000Z"), + ], + }, + { + __typename: "Speaker", + id: "speaker-2", + name: "Bob", + availableTimes: [new Date("2026-09-15T10:00:00.000Z"), null], + }, + ], + sessions: [ + { + __typename: "Session", + id: "session-1", + startTime: new Date("2026-09-15T09:00:00.000Z"), + metadata: new Map([["dress", "casual"]]), + }, + ], + }, + }); +}); + +test("preserves an existing scalar option when policies.addTypePolicies updates another field option", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { + scalar: "DateTime", + }, + }, + }, + }, + }); + + cache.policies.addTypePolicies({ + Event: { + fields: { + startTime: { + merge: (_, incoming) => incoming, + }, + }, + }, + }); + + const query = gql` + query { + event { + id + startTime + } + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(rawCacheData(cache)).toEqual({ + ROOT_QUERY: { + __typename: "Query", + event: { __ref: "Event:1" }, + }, + "Event:1": { + __typename: "Event", + id: "1", + startTime: new Date("2026-01-01T00:00:00.000Z"), + }, + }); +}); + +test("maintains referential equality with multiple cache reads", () => { + const cache = new InMemoryCache({ + scalars: { DateTime: dateTimeScalar }, + typePolicies: { + Event: { + fields: { + startTime: { + scalar: "DateTime", + }, + }, + }, + }, + }); + + const query: TypedDocumentNode<{ + event: { __typename: "Event"; id: string; startTime: Date }; + }> = gql` + query { + event { + id + startTime + } + } + `; + + const fragment: TypedDocumentNode<{ + __typename: "Event"; + id: string; + startTime: Date; + }> = gql` + fragment EventFragment on Event { + id + startTime + } + `; + + cache.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + // @ts-expect-error TODO: Need to figure out types + startTime: "2026-01-01T00:00:00.000Z", + }, + }, + }); + + const initialValue = cache.readQuery({ query }); + + { + const result = cache.readQuery({ query }); + + expect(result!.event.startTime).toBe(initialValue!.event.startTime); + } + + { + const result = cache.readFragment({ + fragment, + from: { __typename: "Event", id: "1" }, + }); + + expect(result!.startTime).toBe(initialValue!.event.startTime); + } +}); + +// This helper function extracts the raw stored value for tests to actually +// verify we write the parsed value. cache.extract() traverses the result and +// serializes the scalar values which means we can't truly check if the result +// was written correctly. +function rawCacheData(cache: InMemoryCache) { + return cache["data"].toObject(); +} diff --git a/src/cache/inmemory/__tests__/variablesSerialization.ts b/src/cache/inmemory/__tests__/variablesSerialization.ts new file mode 100644 index 00000000000..ebf58214b0d --- /dev/null +++ b/src/cache/inmemory/__tests__/variablesSerialization.ts @@ -0,0 +1,1412 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { + dateTimeScalar, + jsonObjectScalar, + priceScalar, +} from "@apollo/client/testing/internal"; + +test("serializes a custom scalar variable", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!) { + createEvent(startsAt: $startsAt) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + }) + ).toStrictEqualTyped({ startsAt: "2026-01-01T00:00:00.000Z" }); +}); + +test("leaves an already serialized custom scalar variable unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!) { + createEvent(startsAt: $startsAt) { + id + } + } + `; + + const variables = { + startsAt: "2026-01-01T00:00:00.000Z", + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt: "2026-01-01T00:00:00.000Z", + }); + expect(result).toBe(variables); +}); + +test("does not coerce a null custom scalar variable", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), + // Allow nulls through to ensure we don't call serialize. + is: (value) => value === null || value instanceof Date, + }), + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime) { + createEvent(startsAt: $startsAt) { + id + } + } + `; + const variables = { + startsAt: null, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt: null, + }); + expect(result).toBe(variables); +}); + +test("serializes variables in a document containing fragment definitions", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const query = gql` + query Event($startsAt: DateTime!) { + event(startsAt: $startsAt) { + ...EventFragment + } + } + + fragment EventFragment on Event { + id + name + } + `; + + expect( + cache.serializeVariables(query, { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + }) + ).toStrictEqualTyped({ + startsAt: "2026-01-01T00:00:00.000Z", + }); +}); + +test("serializes custom scalar variables whose parsed type is a primitive", () => { + const cache = new InMemoryCache({ + scalars: { + Price: priceScalar, + }, + }); + + const mutation = gql` + mutation PurchaseTicket($price: Price!) { + purchaseTicket(price: $price) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { price: "19.99" }) + ).toStrictEqualTyped({ + price: 1999, + }); +}); + +test("serializes a scalar object variable in parsed or serialized form", () => { + const cache = new InMemoryCache({ + scalars: { + JSONObject: jsonObjectScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($metadata: JSONObject!) { + createEvent(metadata: $metadata) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + metadata: new Map([ + ["location", "Denver"], + ["capacity", 500], + ]), + }) + ).toStrictEqualTyped({ + metadata: { + location: "Denver", + capacity: 500, + }, + }); + + const variables = { + metadata: { + location: "Denver", + capacity: 500, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + metadata: { + location: "Denver", + capacity: 500, + }, + }); + expect(result).toBe(variables); +}); + +test("serializes lists and nested lists of custom scalars", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation ScheduleEvents($startsAt: [DateTime], $schedule: [[DateTime!]!]!) { + scheduleEvents(startsAt: $startsAt, schedule: $schedule) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + startsAt: [new Date("2026-01-01T00:00:00.000Z"), null], + schedule: [ + [ + new Date("2026-01-02T00:00:00.000Z"), + new Date("2026-01-03T00:00:00.000Z"), + ], + ], + }) + ).toStrictEqualTyped({ + startsAt: ["2026-01-01T00:00:00.000Z", null], + schedule: [["2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]], + }); +}); + +test("leaves lists and nested lists alone when variables are already serialized", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation ScheduleEvents($startsAt: [DateTime], $schedule: [[DateTime!]!]!) { + scheduleEvents(startsAt: $startsAt, schedule: $schedule) { + id + } + } + `; + + const variables = { + startsAt: ["2026-01-01T00:00:00.000Z", null], + schedule: [["2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]], + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt: ["2026-01-01T00:00:00.000Z", null], + schedule: [["2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]], + }); + + expect(result).toBe(variables); +}); + +test("serializes mixed parsed and serialized scalar object list values", () => { + const cache = new InMemoryCache({ + scalars: { + JSONObject: jsonObjectScalar, + }, + }); + + const mutation = gql` + mutation CreateEvents($metadata: [JSONObject!]!) { + createEvents(metadata: $metadata) { + id + } + } + `; + const serializedMetadata = { + location: "Online", + }; + const metadata = [ + new Map([ + ["location", "Denver"], + ["capacity", 500], + ]), + serializedMetadata, + ]; + const variables = { metadata }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + metadata: [ + { + location: "Denver", + capacity: 500, + }, + serializedMetadata, + ], + }); + expect(result).not.toBe(variables); + expect(result.metadata).not.toBe(metadata); + expect(result.metadata[1]).toBe(serializedMetadata); +}); + +test("serializes configured fields in an input object", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + Price: priceScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + ticketPrice: "Price", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + input: { + name: "GraphQL Summit", + startsAt: new Date("2026-01-01T00:00:00.000Z"), + ticketPrice: "19.99", + }, + }) + ).toStrictEqualTyped({ + input: { + name: "GraphQL Summit", + startsAt: "2026-01-01T00:00:00.000Z", + ticketPrice: 1999, + }, + }); +}); + +test("serializes a scalar object field in a configured input object", () => { + const cache = new InMemoryCache({ + scalars: { + JSONObject: jsonObjectScalar, + }, + inputObjects: { + EventInput: { + fields: { + metadata: "JSONObject", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + input: { + name: "GraphQL Summit", + metadata: new Map([ + ["location", "Denver"], + ["capacity", 500], + ]), + }, + }) + ).toStrictEqualTyped({ + input: { + name: "GraphQL Summit", + metadata: { + location: "Denver", + capacity: 500, + }, + }, + }); +}); + +test("serializes mixed scalar object list fields in a configured input object", () => { + const cache = new InMemoryCache({ + scalars: { + JSONObject: jsonObjectScalar, + }, + inputObjects: { + EventInput: { + fields: { + metadata: "JSONObject", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + const serializedMetadata = { + location: "Online", + }; + const metadata = [ + new Map([ + ["location", "Denver"], + ["capacity", 500], + ]), + serializedMetadata, + ]; + const variables = { + input: { + metadata, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + input: { + metadata: [ + { + location: "Denver", + capacity: 500, + }, + serializedMetadata, + ], + }, + }); + expect(result).not.toBe(variables); + expect(result.input).not.toBe(variables.input); + expect(result.input.metadata).not.toBe(metadata); + expect(result.input.metadata[1]).toBe(serializedMetadata); +}); + +test("serializes nested configured input objects", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventFilter: { + fields: { + dateRange: "DateRangeInput", + }, + }, + DateRangeInput: { + fields: { + start: "DateTime", + end: "DateTime", + }, + }, + }, + }); + + const query = gql` + query Events($filter: EventFilter) { + events(filter: $filter) { + id + } + } + `; + + expect( + cache.serializeVariables(query, { + filter: { + search: "keynote", + dateRange: { + start: new Date("2026-01-01T00:00:00.000Z"), + end: new Date("2026-01-02T00:00:00.000Z"), + }, + }, + }) + ).toStrictEqualTyped({ + filter: { + search: "keynote", + dateRange: { + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-02T00:00:00.000Z", + }, + }, + }); +}); + +test("serializes lists of custom scalars in a configured input object", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + AvailabilityInput: { + fields: { + dates: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation SetAvailability($input: AvailabilityInput!) { + setAvailability(input: $input) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + input: { + dates: [ + new Date("2026-01-01T00:00:00.000Z"), + null, + [new Date("2026-01-02T00:00:00.000Z")], + ], + }, + }) + ).toStrictEqualTyped({ + input: { + dates: ["2026-01-01T00:00:00.000Z", null, ["2026-01-02T00:00:00.000Z"]], + }, + }); +}); + +test("serializes lists and nested lists of configured input objects", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + ScheduleInput: { + fields: { + sessions: "SessionInput", + }, + }, + SessionInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateSchedules($schedules: [[ScheduleInput!]!]!) { + createSchedules(schedules: $schedules) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + schedules: [ + [ + { + name: "Day one", + sessions: [ + { + title: "Keynote", + startsAt: new Date("2026-01-01T09:00:00.000Z"), + }, + null, + ], + }, + ], + ], + }) + ).toStrictEqualTyped({ + schedules: [ + [ + { + name: "Day one", + sessions: [ + { + title: "Keynote", + startsAt: "2026-01-01T09:00:00.000Z", + }, + null, + ], + }, + ], + ], + }); +}); + +test("serializes recursive input objects", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventFilter: { + fields: { + startsAt: "DateTime", + and: "EventFilter", + }, + }, + }, + }); + + const query = gql` + query Events($filter: EventFilter!) { + events(filter: $filter) { + id + } + } + `; + + expect( + cache.serializeVariables(query, { + filter: { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + and: [ + { + startsAt: new Date("2026-01-02T00:00:00.000Z"), + }, + ], + }, + }) + ).toStrictEqualTyped({ + filter: { + startsAt: "2026-01-01T00:00:00.000Z", + and: [ + { + startsAt: "2026-01-02T00:00:00.000Z", + }, + ], + }, + }); +}); + +test("preserves null and omitted input object fields", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + endsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput, $fallback: DateTime) { + createEvent(input: $input, fallback: $fallback) { + id + } + } + `; + + const variables = { + input: { + startsAt: null, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + input: { + startsAt: null, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves a null configured input object unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput) { + createEvent(input: $input) { + id + } + } + `; + const variables = { + input: null, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + input: null, + }); + expect(result).toBe(variables); +}); + +test("leaves built-in scalar fields and unconfigured fields unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent( + $name: String! + $capacity: Int! + $published: Boolean! + $input: EventInput! + ) { + createEvent( + name: $name + capacity: $capacity + published: $published + input: $input + ) { + id + } + } + `; + const metadata = { source: "import" }; + + expect( + cache.serializeVariables(mutation, { + name: "GraphQL Summit", + capacity: 500, + published: true, + input: { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + metadata, + }, + }) + ).toStrictEqualTyped({ + name: "GraphQL Summit", + capacity: 500, + published: true, + input: { + startsAt: "2026-01-01T00:00:00.000Z", + metadata, + }, + }); +}); + +test("leaves an input object unchanged when its type is not configured", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + const startsAt = new Date("2026-01-01T00:00:00.000Z"); + + const variables = { + input: { + startsAt, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + input: { + startsAt, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves variables unchanged when scalars and input objects are not configured", () => { + const cache = new InMemoryCache(); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!, $input: EventInput!) { + createEvent(startsAt: $startsAt, input: $input) { + id + } + } + `; + const startsAt = new Date("2026-01-01T00:00:00.000Z"); + const variables = { + startsAt, + input: { + startsAt, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt, + input: { + startsAt, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves variables unchanged when input objects are configured without their scalars", () => { + const cache = new InMemoryCache({ + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!, $input: EventInput!) { + createEvent(startsAt: $startsAt, input: $input) { + id + } + } + `; + const startsAt = new Date("2026-01-01T00:00:00.000Z"); + const variables = { + startsAt, + input: { + startsAt, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt, + input: { + startsAt, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves a nested input object unchanged when its configuration is missing", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventFilter: { + fields: { + dateRange: "DateRangeInput", + }, + }, + }, + }); + + const query = gql` + query Events($filter: EventFilter!) { + events(filter: $filter) { + id + } + } + `; + const start = new Date("2026-01-01T00:00:00.000Z"); + const variables = { + filter: { + dateRange: { + start, + }, + }, + }; + + const result = cache.serializeVariables(query, variables); + + expect(result).toStrictEqualTyped({ + filter: { + dateRange: { + start, + }, + }, + }); + expect(result).toBe(variables); +}); + +test("does not use a nested input object configuration when its parent is not configured", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + DateRangeInput: { + fields: { + start: "DateTime", + }, + }, + }, + }); + + const query = gql` + query Events($filter: EventFilter!) { + events(filter: $filter) { + id + } + } + `; + const start = new Date("2026-01-01T00:00:00.000Z"); + const variables = { + filter: { + dateRange: { + start, + }, + }, + }; + + const result = cache.serializeVariables(query, variables); + + expect(result).toStrictEqualTyped({ + filter: { + dateRange: { + start, + }, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves configured input objects unchanged when values are already serialized", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventFilter: { + fields: { + dateRange: "DateRangeInput", + }, + }, + DateRangeInput: { + fields: { + start: "DateTime", + end: "DateTime", + }, + }, + }, + }); + + const query = gql` + query Events($filter: EventFilter!) { + events(filter: $filter) { + id + } + } + `; + const variables = { + filter: { + dateRange: { + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-02T00:00:00.000Z", + }, + }, + }; + + const result = cache.serializeVariables(query, variables); + + expect(result).toStrictEqualTyped({ + filter: { + dateRange: { + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-02T00:00:00.000Z", + }, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves lists of configured input objects unchanged when values are already serialized", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvents($inputs: [EventInput!]!) { + createEvents(inputs: $inputs) { + id + } + } + `; + const firstInput = { + name: "Opening keynote", + startsAt: "2026-01-01T09:00:00.000Z", + }; + const secondInput = { + name: "Closing keynote", + startsAt: "2026-01-01T17:00:00.000Z", + }; + const inputs = [firstInput, secondInput]; + const variables = { inputs }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + inputs: [firstInput, secondInput], + }); + expect(result).toBe(variables); + expect(result.inputs).toBe(inputs); + expect(result.inputs[0]).toBe(firstInput); + expect(result.inputs[1]).toBe(secondInput); +}); + +test("preserves unchanged references when another input field is serialized", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!, $context: JSONObject) { + createEvent(input: $input, context: $context) { + id + } + } + `; + const metadata = { source: "import" }; + const tags = ["conference"]; + const context = { requestId: "1" }; + const variables = { + input: { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + metadata, + tags, + }, + context, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + input: { + startsAt: "2026-01-01T00:00:00.000Z", + metadata, + tags, + }, + context, + }); + expect(result).not.toBe(variables); + expect(result.input).not.toBe(variables.input); + expect(result.input.metadata).toBe(metadata); + expect(result.input.tags).toBe(tags); + expect(result.context).toBe(context); +}); + +test("preserves unrelated top-level variable references when another variable is serialized", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!, $context: JSONObject) { + createEvent(startsAt: $startsAt, context: $context) { + id + } + } + `; + const context = { + requestId: "1", + }; + const variables = { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + context, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt: "2026-01-01T00:00:00.000Z", + context, + }); + expect(result).not.toBe(variables); + expect(result.context).toBe(context); +}); + +test("serializes multiple independent variable branches", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + endsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime!, $input: EventInput!) { + createEvent(startsAt: $startsAt, input: $input) { + id + } + } + `; + + expect( + cache.serializeVariables(mutation, { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + input: { + endsAt: new Date("2026-01-02T00:00:00.000Z"), + }, + }) + ).toStrictEqualTyped({ + startsAt: "2026-01-01T00:00:00.000Z", + input: { + endsAt: "2026-01-02T00:00:00.000Z", + }, + }); +}); + +test("leaves an empty variables object unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime) { + createEvent(startsAt: $startsAt) { + id + } + } + `; + const variables = {}; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({}); + expect(result).toBe(variables); +}); + +test("does not add an omitted variable with a default value", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime = "2026-01-01T00:00:00.000Z") { + createEvent(startsAt: $startsAt) { + id + } + } + `; + const variables = {}; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({}); + expect(result).toBe(variables); +}); + +test("leaves variables unchanged when the operation has no variable definitions", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const query = gql` + query Event { + event { + id + } + } + `; + const startsAt = new Date("2026-01-01T00:00:00.000Z"); + const variables = { startsAt }; + + const result = cache.serializeVariables(query, variables); + + expect(result).toStrictEqualTyped({ startsAt }); + expect(result).toBe(variables); +}); + +test("leaves variables not declared by the operation unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime) { + createEvent(startsAt: $startsAt) { + id + } + } + `; + const context = { requestId: "1" }; + const variables = { context }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ context }); + expect(result).toBe(variables); + expect(result.context).toBe(context); +}); + +test("leaves explicitly undefined scalar and input fields unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($startsAt: DateTime, $input: EventInput) { + createEvent(startsAt: $startsAt, input: $input) { + id + } + } + `; + const variables = { + startsAt: undefined, + input: { + startsAt: undefined, + }, + }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ + startsAt: undefined, + input: { + startsAt: undefined, + }, + }); + expect(result).toBe(variables); +}); + +test("leaves an empty configured input object unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + const input = {}; + const variables = { input }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ input: {} }); + expect(result).toBe(variables); + expect(result.input).toBe(input); +}); + +test("leaves empty scalar and input object lists unchanged", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvents($startsAt: [DateTime!]!, $inputs: [EventInput!]!) { + createEvents(startsAt: $startsAt, inputs: $inputs) { + id + } + } + `; + const startsAt: Date[] = []; + const inputs: Array<{ startsAt: Date }> = []; + const variables = { startsAt, inputs }; + + const result = cache.serializeVariables(mutation, variables); + + expect(result).toStrictEqualTyped({ startsAt: [], inputs: [] }); + expect(result).toBe(variables); + expect(result.startsAt).toBe(startsAt); + expect(result.inputs).toBe(inputs); +}); + +test("does not mutate the provided variables", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + id + } + } + `; + const startsAt = new Date("2026-01-01T00:00:00.000Z"); + const variables = { + input: { + name: "GraphQL Summit", + startsAt, + }, + }; + + cache.serializeVariables(mutation, variables); + + expect(variables).toStrictEqualTyped({ + input: { + name: "GraphQL Summit", + startsAt, + }, + }); + expect(variables.input.startsAt).toBe(startsAt); +}); + +test("serializes variables for subscriptions", () => { + const cache = new InMemoryCache({ + scalars: { + DateTime: dateTimeScalar, + }, + inputObjects: { + EventInput: { + fields: { + startsAt: "DateTime", + }, + }, + }, + }); + + const subscription = gql` + subscription EventCreated($filter: EventInput!) { + eventCreated(filter: $filter) { + id + } + } + `; + + expect( + cache.serializeVariables(subscription, { + filter: { + startsAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }) + ).toStrictEqualTyped({ + filter: { + startsAt: "2026-01-01T00:00:00.000Z", + }, + }); +}); diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index ce139e603f9..d2bfcc1b578 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -14,11 +14,13 @@ import { __DEV__ } from "@apollo/client/utilities/environment"; import { DeepMerger, isNonNullObject, + isPlainObject, makeReference, maybeDeepFreeze, } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; +import type { Scalar } from "../core/Scalar.js"; import type { Cache } from "../core/types/Cache.js"; import type { CanReadFunction, @@ -122,7 +124,7 @@ export abstract class EntityStore implements NormalizedCache { const existing: StoreObject | undefined = typeof older === "string" ? this.lookup((dataId = older)) : older; - const incoming: StoreObject | undefined = + let incoming: StoreObject | undefined = typeof newer === "string" ? this.lookup((dataId = newer)) : newer; // If newer was a string ID, but that ID was not defined in this store, @@ -131,6 +133,16 @@ export abstract class EntityStore implements NormalizedCache { invariant(typeof dataId === "string", "store.merge expects a string ID"); + // Parse all scalars before merging so that the storeObjectReconciler can + // deep compare the parsed value with the existing value + incoming = this.coerceStoreObject( + incoming, + (scalar, value) => scalar.coerceToParsed(value), + incoming.__typename || + existing?.__typename || + this.policies.rootTypenamesById[dataId] + ); + const merged: StoreObject = new DeepMerger({ reconciler: storeObjectReconciler, }).merge(existing, incoming); @@ -390,7 +402,18 @@ export abstract class EntityStore implements NormalizedCache { } public extract(): NormalizedCacheObject { - const obj = this.toObject(); + const obj = Object.fromEntries( + Object.entries(this.toObject()).map(([dataId, storeObject]) => [ + dataId, + storeObject && + this.coerceStoreObject( + storeObject, + (scalar, value) => scalar.coerceToSerialized(value), + storeObject?.__typename || this.policies.rootTypenamesById[dataId] + ), + ]) + ); + const extraRootIds: string[] = []; this.getRootIdSet().forEach((id) => { if (!hasOwn.call(this.policies.rootTypenamesById, id)) { @@ -403,6 +426,56 @@ export abstract class EntityStore implements NormalizedCache { return obj; } + private coerceStoreObject( + obj: StoreObject, + coerce: (scalar: Scalar, value: unknown) => unknown, + typename = obj.__typename + ): StoreObject { + if (!typename || !this.policies.cache["config"].scalars) { + return obj; + } + + let changed = false; + + const entries = Object.entries(obj).map(([storeFieldName, value]) => { + const scalar = this.policies.getScalarForField( + typename, + fieldNameFromStoreName(storeFieldName) + ); + + const newValue = this.coerceValue(value, coerce, scalar); + changed ||= newValue !== value; + + return [storeFieldName, newValue]; + }); + + return changed ? Object.fromEntries(entries) : obj; + } + + private coerceValue( + value: unknown, + coerce: (scalar: Scalar, value: unknown) => unknown, + scalar?: Scalar + ): unknown { + if (value == null) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => this.coerceValue(item, coerce, scalar)); + } + + if (scalar) { + return coerce(scalar, value); + } + + if (isPlainObject(value) && "__typename" in value) { + return this.coerceStoreObject(value, coerce); + } + + return value; + } + public replace(newData: NormalizedCacheObject | null): void { Object.keys(this.data).forEach((dataId) => { if (!(newData && hasOwn.call(newData, dataId))) { diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index f5ad7c518c4..0c9425fb454 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -7,7 +7,7 @@ import type { import type { OptimisticWrapperFunction } from "optimism"; import { wrap } from "optimism"; -import type { OperationVariables } from "@apollo/client"; +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; import type { DeepPartial, Reference, @@ -22,11 +22,21 @@ import { print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import { getInMemoryCacheMemoryInternals } from "@apollo/client/utilities/internal"; +import type { + IsLooselyEqual, + NoInfer, +} from "@apollo/client/utilities/internal"; +import { + getInMemoryCacheMemoryInternals, + getOperationDefinition, + getUnwrappedType, + isPlainObject, +} from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; import { defaultCacheSizes } from "../../utilities/caching/sizes.js"; import { ApolloCache } from "../core/cache.js"; +import type { Scalar } from "../core/Scalar.js"; import type { Cache } from "../core/types/Cache.js"; import { EntityStore, supportsResultCaching } from "./entityStore.js"; @@ -34,7 +44,11 @@ import { hasOwn, normalizeConfig } from "./helpers.js"; import { Policies } from "./policies.js"; import { forgetCache, makeVar, recallCache } from "./reactiveVars.js"; import { StoreReader } from "./readFromStore.js"; -import type { InMemoryCacheConfig, NormalizedCacheObject } from "./types.js"; +import type { + InMemoryCacheConfig, + KnownScalars, + NormalizedCacheObject, +} from "./types.js"; import { StoreWriter } from "./writeToStore.js"; type BroadcastOptions = Pick< @@ -42,6 +56,36 @@ type BroadcastOptions = Pick< "optimistic" | "onWatchUpdated" >; +export declare namespace InMemoryCache { + export type ScalarsOption = { + [ScalarName in keyof KnownScalars as IsLooselyEqual< + KnownScalars[ScalarName]["serialized"], + KnownScalars[ScalarName]["parsed"] + > extends true ? + ScalarName + : never]?: KnownScalars[ScalarName] extends ( + { serialized: infer TSerialized; parsed: infer TParsed } + ) ? + Scalar + : never; + } & { + [ScalarName in keyof KnownScalars as IsLooselyEqual< + KnownScalars[ScalarName]["serialized"], + KnownScalars[ScalarName]["parsed"] + > extends true ? + never + : ScalarName]: KnownScalars[ScalarName] extends ( + { serialized: infer TSerialized; parsed: infer TParsed } + ) ? + Scalar + : never; + } & (ApolloCache.Scalars extends ( + Record + ) ? + Record> + : {}); +} + export class InMemoryCache extends ApolloCache { private data!: EntityStore; private optimisticData!: EntityStore; @@ -70,9 +114,13 @@ export class InMemoryCache extends ApolloCache { public readonly makeVar = makeVar; - constructor(config: InMemoryCacheConfig = {}) { + constructor( + ...args: {} extends InMemoryCache.ScalarsOption ? + [config?: InMemoryCacheConfig] + : [config: InMemoryCacheConfig] + ) { super(); - this.config = normalizeConfig(config); + this.config = normalizeConfig(args[0] ?? {}); this.policies = new Policies({ cache: this, @@ -156,6 +204,150 @@ export class InMemoryCache extends ApolloCache { ); } + public getScalar( + key: TKey + ): ApolloCache.GetScalarType extends ( + Scalar + ) ? + IsLooselyEqual extends true ? + // We don't require scalars where the serialized/parsed types are equal, so we + // can't guarantee we will get a value + ApolloCache.GetScalarType | undefined + : ApolloCache.GetScalarType + : never { + return this.config.scalars?.[key as string] as any; + } + + /** + * {@inheritDoc @apollo/client/cache!ApolloCache#serializeVariables:member(1)} + */ + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer + ): TVariables; + + /** + * {@inheritDoc @apollo/client/cache!ApolloCache#serializeVariables:member(1)} + */ + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer | undefined + ): TVariables | undefined; + + public serializeVariables< + TVariables extends OperationVariables = OperationVariables, + >( + document: DocumentNode | TypedDocumentNode, + variables: NoInfer | undefined + ): TVariables | undefined { + if ( + !variables || + Object.keys(variables).length === 0 || + (!this.config.scalars && !this.config.inputObjects) + ) { + return variables; + } + + const variableTypes = getOperationDefinition( + document + )?.variableDefinitions?.reduce>((memo, node) => { + memo[node.variable.name.value] = getUnwrappedType(node.type); + + return memo; + }, {}); + + if (!variableTypes || Object.keys(variableTypes).length === 0) { + return variables; + } + + return this.serializeVariablesValue(variables, variableTypes) as TVariables; + } + + private serializeVariablesValue( + value: unknown, + variableTypes: Record, + scalar?: Scalar + ): unknown { + if (Array.isArray(value)) { + return this.serializeInputArray(value, variableTypes, scalar); + } + + if (scalar && value != null) { + return scalar.coerceToSerialized(value); + } + + if (isPlainObject(value)) { + return this.serializeInputObject(value, variableTypes); + } + + return value; + } + + private serializeInputArray( + value: unknown[], + variableTypes: Record, + scalar?: Scalar + ) { + let changed = false; + + const newValue = value.map((item) => { + const newItem = this.serializeVariablesValue(item, variableTypes, scalar); + changed ||= newItem !== item; + + return newItem; + }); + + return changed ? newValue : value; + } + + private serializeInputObject( + value: Record, + variableTypes: Record + ) { + let changed = false; + + const entries = Object.entries(value).map(([name, value]) => { + const type = variableTypes[name]; + + if (!type) { + return [name, value]; + } + + const inputObject = this.config.inputObjects?.[type]; + + if (inputObject) { + const newValue = this.serializeVariablesValue( + value, + inputObject.fields + ); + changed ||= newValue !== value; + + return [name, newValue]; + } + + const scalar = this.getScalar(type); + + if (scalar) { + const newValue = this.serializeVariablesValue( + value, + variableTypes, + scalar + ); + changed ||= newValue !== value; + + return [name, newValue]; + } + + return [name, value]; + }); + + return changed ? Object.fromEntries(entries) : value; + } + public restore(data: NormalizedCacheObject): this { this.init(); // Since calling this.init() discards/replaces the entire StoreReader, along @@ -183,6 +375,8 @@ export class InMemoryCache extends ApolloCache { options: Cache.ReadOptions ): TData | DeepPartial | null { const { + query, + variables, // Since read returns data or null, without any additional metadata // about whether/where there might have been missing fields, the // default behavior cannot be returnPartialData = true (like it is @@ -195,6 +389,7 @@ export class InMemoryCache extends ApolloCache { return this.storeReader.diffQueryAgainstStore({ ...options, + variables: this.serializeVariables(query, variables), store: options.optimistic ? this.optimisticData : this.data, config: this.config, returnPartialData, @@ -205,9 +400,14 @@ export class InMemoryCache extends ApolloCache { TData = unknown, TVariables extends OperationVariables = OperationVariables, >(options: Cache.WriteOptions): Reference | undefined { + const { query, variables } = options; + try { ++this.txCount; - return this.storeWriter.writeToStore(this.data, options); + return this.storeWriter.writeToStore(this.data, { + ...options, + variables: this.serializeVariables(query, variables), + }); } finally { if (!--this.txCount && options.broadcast !== false) { this.broadcastWatches(); @@ -250,8 +450,11 @@ export class InMemoryCache extends ApolloCache { TData = unknown, TVariables extends OperationVariables = OperationVariables, >(options: Cache.DiffOptions): Cache.DiffResult { + const { variables } = options; + return this.storeReader.diffQueryAgainstStore({ ...options, + variables: this.serializeVariables(options.query, variables), store: options.optimistic ? this.optimisticData : this.data, rootId: options.id || "ROOT_QUERY", config: this.config, diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 79c6762b0a4..f5c7959bd2c 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -31,6 +31,8 @@ import { newInvariantError, } from "@apollo/client/utilities/invariant"; +import type { ApolloCache } from "../core/cache.js"; +import type { Scalar } from "../core/Scalar.js"; import type { CanReadFunction, FieldSpecifier, @@ -59,6 +61,7 @@ import type { MergeInfo, NormalizedCache, ReadMergeModifyContext, + ScalarNames, } from "./types.js"; import type { WriteContext } from "./writeToStore.js"; @@ -173,6 +176,7 @@ export type FieldPolicy< keyArgs?: KeySpecifier | KeyArgsFunction | false; read?: FieldReadFunction; merge?: FieldMergeFunction | boolean; + scalar?: ScalarNames; }; export type StorageType = Record; @@ -354,6 +358,7 @@ type InternalFieldPolicy = { keyFn?: KeyArgsFunction; read?: FieldReadFunction; merge?: FieldMergeFunction; + scalar?: keyof ApolloCache.Scalars; }; export class Policies { @@ -565,7 +570,11 @@ export class Policies { if (typeof incoming === "function") { existing.read = incoming; } else { - const { keyArgs, read, merge } = incoming; + const { keyArgs, read, merge, scalar } = incoming; + + if (scalar) { + existing.scalar = scalar; + } existing.keyFn = // Pass false to disable argument-based differentiation of @@ -887,6 +896,15 @@ export class Policies { : fieldName + ":" + storeFieldName; } + public getScalarForField( + typename: string, + fieldName: string + ): Scalar | undefined { + const policy = this.getFieldPolicy(typename, fieldName); + + return policy?.scalar ? this.cache.getScalar(policy.scalar) : undefined; + } + public readField( options: ReadFieldOptions, context: ReadMergeModifyContext diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index 1d1bf3ee828..f331f45615b 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -361,6 +361,25 @@ export class StoreReader { } else if (!selection.selectionSet) { // do nothing } else if (fieldValue != null) { + if (__DEV__) { + const typename = context.store.getFieldValue( + objectOrReference, + "__typename" + ); + const fieldName = selection.name.value; + + if (typename) { + const policy = policies["getFieldPolicy"](typename, fieldName); + + if (policy?.scalar) { + invariant.warn( + "The field policy for '%s' is configured as a '%s' scalar, but the field is not a scalar field because it contains a selection set. The field value remains unchanged.", + `${typename}.${fieldName}`, + policy.scalar + ); + } + } + } // In this case, because we know the field has a selection set, // it must be trying to query a GraphQLObjectType, which is why // fieldValue must be != null. diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 1b309fc9d36..b010351130d 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -6,9 +6,12 @@ import type { StoreObject, StoreValue, } from "@apollo/client/utilities"; -import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal"; +import type { + ExtensionsWithStreamInfo, + RemoveIndexSignature, +} from "@apollo/client/utilities/internal"; -import type { Transaction } from "../core/cache.js"; +import type { ApolloCache, Transaction } from "../core/cache.js"; import type { AllFieldsModifier, CanReadFunction, @@ -18,6 +21,7 @@ import type { import type { FieldValueGetter } from "./entityStore.js"; import type { FragmentRegistryAPI } from "./fragmentRegistry.js"; +import type { InMemoryCache } from "./inMemoryCache.js"; import type { FieldMergeFunction, KeyFieldsFunction, @@ -135,12 +139,30 @@ export type ApolloReducerConfig = { dataIdFromObject?: KeyFieldsFunction; }; -export interface InMemoryCacheConfig extends ApolloReducerConfig { +export interface InputObjectConfig { + fields: Record; +} + +export interface InputObjectsOption { + [inputObjectName: string]: InputObjectConfig; +} + +export type InMemoryCacheConfig = ApolloReducerConfig & { resultCaching?: boolean; possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; fragments?: FragmentRegistryAPI; -} + inputObjects?: InputObjectsOption; +} & ({} extends InMemoryCache.ScalarsOption ? + InMemoryCache.ScalarsOption extends Record ? + { + scalars?: Record< + string, + `Scalar types must be declared in ApolloCache.Scalars before usage. See https://www.apollographql.com/docs/react/data/typescript#declaring-scalar-types.` + >; + } + : { scalars?: InMemoryCache.ScalarsOption } + : { scalars: InMemoryCache.ScalarsOption }); export interface MergeInfo { field: FieldNode; @@ -161,3 +183,8 @@ export interface ReadMergeModifyContext { varString?: string; extensions?: ExtensionsWithStreamInfo; } + +export type KnownScalars = RemoveIndexSignature; +export type ScalarNames = + | keyof KnownScalars + | (string extends keyof ApolloCache.Scalars ? string & {} : never); diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index 56cecf4b605..8b3d0162028 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -480,43 +480,39 @@ export class QueryInfo< // Determine whether result is a SingleExecutionResult, // or the final ExecutionPatchResult. - if (update) { - if (!skipCache) { - // Re-read the ROOT_MUTATION data we just wrote into the cache - // (the first cache.write call in the cacheWrites.forEach loop - // above), so field read functions have a chance to run for - // fields within mutation result objects. - const diff = cache.diff({ - id: "ROOT_MUTATION", - // The cache complains if passed a mutation where it expects a - // query, so we transform mutations and subscriptions to queries - // (only once, thanks to this.transformCache). - query: this.queryManager.getDocumentInfo(mutation.document) - .asQuery, - variables: mutation.variables, - optimistic: false, - returnPartialData: true, - }); + // Re-read from the cache after writing to it to update `result` + // with any parsed scalar values that might have been written. + if (!skipCache) { + const diff = cache.diff({ + id: "ROOT_MUTATION", + // The cache complains if passed a mutation where it expects a + // query, so we transform mutations and subscriptions to queries + // (only once, thanks to this.transformCache). + query: this.queryManager.getDocumentInfo(mutation.document) + .asQuery, + variables: mutation.variables, + optimistic: false, + returnPartialData: true, + }); - if (diff.complete) { - result = { - ...result, - data: diff.result, - }; - } + if (diff.complete) { + result = { + ...result, + data: diff.result, + }; } + } - // If we've received the whole response, call the update function. - if (!this.hasNext) { - update( - cache as TCache, - result as FormattedExecutionResult>, - { - context: mutation.context, - variables: mutation.variables, - } - ); - } + // If we've received the whole response, call the update function. + if (update && !this.hasNext) { + update( + cache as TCache, + result as FormattedExecutionResult>, + { + context: mutation.context, + variables: mutation.variables, + } + ); } // TODO Do this with cache.evict({ id: 'ROOT_MUTATION' }) but make it @@ -614,6 +610,22 @@ export class QueryInfo< variables: variables, extensions: result.extensions, }); + + // Re-read from the cache to get parsed scalar values + const diff = this.cache.diff({ + // The cache complains if passed a mutation where it expects a + // query, so we transform mutations and subscriptions to queries + // (only once, thanks to this.transformCache). + query: this.queryManager.getDocumentInfo(document).asQuery, + id: "ROOT_SUBSCRIPTION", + variables, + optimistic: false, + returnPartialData: true, + }); + + if (diff.complete) { + result.data = diff.result as any; + } } this.queryManager.broadcastQueries(); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 334a2824ecf..c825d24f4c1 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -244,7 +244,7 @@ export class QueryManager { return this.client.link; } - get cache() { + get cache(): Cache.Implementation { return this.client.cache; } @@ -314,7 +314,7 @@ export class QueryManager { this.mutationStore && (this.mutationStore[queryInfo.id] = { mutation, - variables, + variables: this.cache.serializeVariables(mutation, variables), loading: true, error: null, } as MutationStoreValue); @@ -909,6 +909,7 @@ export class QueryManager { const executeContext: ApolloLink.ExecuteContext = { client: this.client, }; + variables = this.cache.serializeVariables(query, variables); if (serverQuery) { const { inFlightLinkObservables, link } = this; diff --git a/src/core/__tests__/client.mutate/customScalars.test.ts b/src/core/__tests__/client.mutate/customScalars.test.ts new file mode 100644 index 00000000000..c2444645784 --- /dev/null +++ b/src/core/__tests__/client.mutate/customScalars.test.ts @@ -0,0 +1,660 @@ +import { delay, of } from "rxjs"; + +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { MockLink } from "@apollo/client/testing"; +import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { createEvent: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const mutation = gql` + mutation CreateEvent($date: Date!) { + createEvent(date: $date) { + name + } + } + `; + + await expect( + client.mutate({ + mutation, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { createEvent: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const mutation = gql` + mutation CreateEvent($date: Date!) { + createEvent @on(date: $date) { + name + } + } + `; + + await expect( + client.mutate({ + mutation, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { createEvent: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventInput: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + name + } + } + `; + + await expect( + client.mutate({ + mutation, + variables: { + input: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ + input: { + date: "2026-01-01", + }, + }); +}); + +test("parses custom scalar fields with a network-only fetch policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ + mutation, + fetchPolicy: "network-only", + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ + mutation, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + } +); + +test("parses parsed custom scalar fields in optimistic responses", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const fragment = gql` + fragment EventFragment on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + + const promise = client.mutate({ + mutation, + optimisticResponse: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + expect( + client.cache.readFragment({ + id: "Event:1", + fragment, + optimistic: true, + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }); +}); + +test("parses serialized custom scalar fields in optimistic responses", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const fragment = gql` + fragment EventFragment on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + + const promise = client.mutate({ + mutation, + optimisticResponse: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + expect( + client.cache.readFragment({ + id: "Event:1", + fragment, + optimistic: true, + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }); +}); + +test("passes parsed custom scalar fields to mutation updater callbacks", async () => { + const mutation: TypedDocumentNode<{ + createEvent: { __typename: "Event"; id: string; startDate: Date }; + }> = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const query = gql` + query LastCreatedEvent { + lastCreatedEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ + mutation, + update(cache, result) { + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + cache.writeQuery({ + query, + data: { + lastCreatedEvent: result.data!.createEvent, + }, + }); + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + lastCreatedEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields in queries triggered by refetchQueries", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + let requestCount = 0; + const link = new MockLink([ + { + request: { query }, + maxUsageCount: 2, + delay: 20, + result: () => { + requestCount++; + + return { + data: { + event: { + __typename: "Event", + id: "1", + startDate: requestCount === 1 ? "2026-01-01" : "2026-03-03", + }, + }, + }; + }, + }, + { + request: { query: mutation }, + delay: 20, + result: { + data: { + createEvent: { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + }, + }, + }, + ]); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link, + }); + + using stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + client.mutate({ + mutation, + refetchQueries: [query], + awaitRefetchQueries: true, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 2, 3), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 2, 3), + }, + }); +}); diff --git a/src/core/__tests__/client.query/customScalars.test.ts b/src/core/__tests__/client.query/customScalars.test.ts new file mode 100644 index 00000000000..b4071d63b4b --- /dev/null +++ b/src/core/__tests__/client.query/customScalars.test.ts @@ -0,0 +1,445 @@ +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + await expect( + client.query({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("uses serialized scalar variables when reading from the cache", async () => { + const link = jest.fn(() => of({ data: { event: null } })); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link: new ApolloLink(link), + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + const variables = { + date: new Date(2026, 0, 1), + }; + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + variables, + }); + + await expect(client.query({ query, variables })).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(link).not.toHaveBeenCalled(); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + await expect( + client.query({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + await expect( + client.query({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("parses cached custom scalar fields with a cache-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + await expect( + client.query({ query, fetchPolicy: "cache-only" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); + +test("parses cached custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + await expect( + client.query({ query, fetchPolicy: "cache-first" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); + +test("parses network custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.query({ query, fetchPolicy: "cache-first" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); + +test("parses network custom scalar fields with a network-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.query({ query, fetchPolicy: "network-only" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + await expect( + client.query({ + query, + fetchPolicy: "no-cache", + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + } +); diff --git a/src/core/__tests__/client.readFragment/customScalars.test.ts b/src/core/__tests__/client.readFragment/customScalars.test.ts new file mode 100644 index 00000000000..92684802066 --- /dev/null +++ b/src/core/__tests__/client.readFragment/customScalars.test.ts @@ -0,0 +1,45 @@ +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("returns parsed custom scalar fields", () => { + const fragment = gql` + fragment EventFields on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }); + + expect( + client.readFragment({ + fragment, + id: "Event:1", + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); +}); diff --git a/src/core/__tests__/client.readQuery/customScalars.test.ts b/src/core/__tests__/client.readQuery/customScalars.test.ts new file mode 100644 index 00000000000..16890c39044 --- /dev/null +++ b/src/core/__tests__/client.readQuery/customScalars.test.ts @@ -0,0 +1,164 @@ +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { date: "2026-01-01" }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect( + client.readQuery({ + query, + variables: { date: new Date(2026, 0, 1) }, + }) + ).toStrictEqualTyped({ + event: { __typename: "Event", name: "GraphQL Summit" }, + }); +}); + +test("serializes scalar variables used in directive arguments", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Query: { + fields: { + event: { + keyArgs: ["@on", ["date"]], + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { date: "2026-01-01" }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect( + client.readQuery({ + query, + variables: { date: new Date(2026, 0, 1) }, + }) + ).toStrictEqualTyped({ + event: { __typename: "Event", name: "GraphQL Summit" }, + }); +}); + +test("serializes scalar fields in input object variables", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { filter: { date: "2026-01-01" } }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect( + client.readQuery({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).toStrictEqualTyped({ + event: { __typename: "Event", name: "GraphQL Summit" }, + }); +}); + +test("returns parsed custom scalar fields", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event { + event { + startDate + } + } + `; + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + startDate: "2026-01-01", + }, + }, + }); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + event: { + __typename: "Event", + startDate: new Date(2026, 0, 1), + }, + }); +}); diff --git a/src/core/__tests__/client.refetchQueries/customScalars.test.ts b/src/core/__tests__/client.refetchQueries/customScalars.test.ts new file mode 100644 index 00000000000..859eb613699 --- /dev/null +++ b/src/core/__tests__/client.refetchQueries/customScalars.test.ts @@ -0,0 +1,200 @@ +import { of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { MockLink } from "@apollo/client/testing"; +import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; + +test("serializes scalar variables when refetching an active query", async () => { + let requestVariables!: OperationVariables; + let requestCount = 0; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + requestCount++; + + return of({ + data: { + event: { + __typename: "Event", + name: `GraphQL Summit ${requestCount}`, + }, + }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + const observable = client.watchQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + notifyOnNetworkStatusChange: false, + }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit 1", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + client.refetchQueries({ include: [query] }) + ).resolves.toStrictEqualTyped([ + { + data: { + event: { + __typename: "Event", + name: "GraphQL Summit 2", + }, + }, + }, + ]); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit 2", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("parses custom scalar fields in refetched queries", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + let requestCount = 0; + const link = new MockLink([ + { + request: { query }, + maxUsageCount: 2, + delay: 20, + result: () => { + requestCount++; + + return { + data: { + event: { + __typename: "Event", + id: "1", + startDate: requestCount === 1 ? "2026-01-01" : "2026-02-02", + }, + }, + }; + }, + }, + ]); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link, + }); + + using stream = new ObservableStream( + client.watchQuery({ + query, + notifyOnNetworkStatusChange: false, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + client.refetchQueries({ include: [query] }) + ).resolves.toStrictEqualTyped([ + { + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }, + ]); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }); +}); diff --git a/src/core/__tests__/client.subscribe/customScalars.test.ts b/src/core/__tests__/client.subscribe/customScalars.test.ts new file mode 100644 index 00000000000..0e77a52a7a6 --- /dev/null +++ b/src/core/__tests__/client.subscribe/customScalars.test.ts @@ -0,0 +1,403 @@ +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + subscription Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using stream = new ObservableStream( + client.subscribe({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + await expect(stream).toComplete(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + subscription Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using stream = new ObservableStream( + client.subscribe({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + await expect(stream).toComplete(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + + const query = gql` + subscription Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using stream = new ObservableStream( + client.subscribe({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + await expect(stream).toComplete(); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("parses custom scalar fields with a cache-only fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ + query: subscription, + fetchPolicy: "cache-only", + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + await expect(stream).toComplete(); +}); + +test("parses custom scalar fields with a cache-first fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ + query: subscription, + fetchPolicy: "cache-first", + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + await expect(stream).toComplete(); +}); + +test("parses custom scalar fields with a network-only fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ + query: subscription, + fetchPolicy: "network-only", + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + await expect(stream).toComplete(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + typePolicies: { + Event: { + fields: { + startDate: { + scalar: "Date", + }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ + query: subscription, + fetchPolicy: "no-cache", + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + await expect(stream).toComplete(); + } +); diff --git a/src/core/__tests__/client.watchFragment/customScalars.test.ts b/src/core/__tests__/client.watchFragment/customScalars.test.ts new file mode 100644 index 00000000000..2090ce22ce3 --- /dev/null +++ b/src/core/__tests__/client.watchFragment/customScalars.test.ts @@ -0,0 +1,71 @@ +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; + +test("emits parsed custom scalar fields", async () => { + const fragment = gql` + fragment EventFields on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }); + + using stream = new ObservableStream( + client.watchFragment({ + fragment, + from: { __typename: "Event", id: "1" }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + dataState: "complete", + complete: true, + }); + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/core/__tests__/client.watchQuery/customScalars.test.ts b/src/core/__tests__/client.watchQuery/customScalars.test.ts new file mode 100644 index 00000000000..c5a3c3cfafa --- /dev/null +++ b/src/core/__tests__/client.watchQuery/customScalars.test.ts @@ -0,0 +1,922 @@ +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using stream = new ObservableStream( + client.watchQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using stream = new ObservableStream( + client.watchQuery({ + query, + variables: { + date: new Date(2026, 0, 1), + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using stream = new ObservableStream( + client.watchQuery({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("serializes scalar variables passed to refetch", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + const observable = client.watchQuery({ + query, + variables: { date: "2025-01-01" }, + notifyOnNetworkStatusChange: false, + }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + observable.refetch({ date: new Date(2026, 0, 1) }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables passed to fetchMore", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + const observable = client.watchQuery({ + query, + variables: { date: "2025-01-01" }, + notifyOnNetworkStatusChange: false, + }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + observable.fetchMore({ + variables: { date: new Date(2026, 0, 1) }, + updateQuery: (_, { fetchMoreResult }) => fetchMoreResult, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables passed to setVariables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + const observable = client.watchQuery({ + query, + variables: { date: "2025-01-01" }, + notifyOnNetworkStatusChange: false, + }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect( + observable.setVariables({ date: new Date(2026, 0, 1) }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables passed to subscribeToMore", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: + operation.operationName === "EventUpdates" ? + { + eventUpdated: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + } + : { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + const subscription = gql` + subscription EventUpdates($date: Date!) { + eventUpdated(date: $date) { + name + } + } + `; + const observable = client.watchQuery({ + query, + variables: { date: "2025-01-01" }, + }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + observable.subscribeToMore({ + document: subscription, + variables: { date: new Date(2026, 0, 1) }, + updateQuery: (_, { subscriptionData }) => ({ + event: (subscriptionData.data as any).eventUpdated, + }), + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("parses cached custom scalar fields with a cache-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("parses cached custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-first" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("parses network custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-first" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("parses cached and network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + partial: false, + }); + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("parses network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "cache-and-network" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test("parses network custom scalar fields with a network-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + using stream = new ObservableStream( + client.watchQuery({ query, fetchPolicy: "network-only" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + await expect(stream).not.toEmitAnything(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.watchQuery({ + query, + fetchPolicy: "no-cache", + }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + } +); diff --git a/src/core/__tests__/client.writeQuery/customScalars.test.ts b/src/core/__tests__/client.writeQuery/customScalars.test.ts new file mode 100644 index 00000000000..738604f5369 --- /dev/null +++ b/src/core/__tests__/client.writeQuery/customScalars.test.ts @@ -0,0 +1,123 @@ +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { dateScalar } from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ scalars: { Date: dateScalar } }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { date: new Date(2026, 0, 1) }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect(client.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event({"date":"2026-01-01"})': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar variables used in directive arguments", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Query: { + fields: { + event: { + keyArgs: ["@on", ["date"]], + }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { date: new Date(2026, 0, 1) }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect(client.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event:{"@on":{"date":"2026-01-01"}}': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); + +test("serializes scalar fields in input object variables", () => { + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + client.writeQuery({ + query, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + data: { + event: { __typename: "Event", name: "GraphQL Summit" }, + }, + }); + + expect(client.extract()).toStrictEqualTyped({ + ROOT_QUERY: { + __typename: "Query", + 'event({"filter":{"date":"2026-01-01"}})': { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); +}); diff --git a/src/core/index.ts b/src/core/index.ts index 6e2d78883d8..7c0129fef18 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -79,6 +79,8 @@ export type { IdGetter, IdGetterObj, InMemoryCacheConfig, + InputObjectConfig, + InputObjectsOption, MergeInfo, MergeTree, NormalizedCache, @@ -104,6 +106,7 @@ export { InMemoryCache, makeVar, MissingFieldError, + Scalar, } from "@apollo/client/cache"; /* Link */ diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 4c5fe1ae53b..e4a84ad7862 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -9,6 +9,10 @@ declare module "@apollo/client" { interface Mutate {} } } + export namespace ApolloCache { + export interface Scalars + extends Record {} + } export interface TypeOverrides { cache: ApolloCache; diff --git a/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx new file mode 100644 index 00000000000..d5f024bfdae --- /dev/null +++ b/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx @@ -0,0 +1,192 @@ +import { disableActEnvironment } from "@testing-library/react-render-stream"; +import { of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useBackgroundQuery } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +import { renderUseBackgroundQuery } from "./testUtils.js"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => + useBackgroundQuery(query, { variables: { date: new Date(2026, 0, 1) } }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: ["useBackgroundQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => + useBackgroundQuery(query, { variables: { date: new Date(2026, 0, 1) } }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: ["useBackgroundQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => + useBackgroundQuery(query, { + variables: { filter: { date: new Date(2026, 0, 1) } }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: ["useBackgroundQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ + filter: { date: "2026-01-01" }, + }); +}); diff --git a/src/react/hooks/__tests__/useFragment/customScalars.test.tsx b/src/react/hooks/__tests__/useFragment/customScalars.test.tsx new file mode 100644 index 00000000000..9b89ea8dde0 --- /dev/null +++ b/src/react/hooks/__tests__/useFragment/customScalars.test.tsx @@ -0,0 +1,83 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useFragment } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("returns parsed custom scalar fields", async () => { + const fragment = gql` + fragment EventFields on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useFragment({ + fragment, + from: { __typename: "Event", id: "1" }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + dataState: "complete", + complete: true, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + dataState: "complete", + complete: true, + }); + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx new file mode 100644 index 00000000000..10118829de4 --- /dev/null +++ b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx @@ -0,0 +1,1058 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useLazyQuery } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect( + execute({ + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.setVariables, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect( + execute({ + variables: { + date: new Date(2026, 0, 1), + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.setVariables, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect( + execute({ + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.setVariables, + previousData: undefined, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("parses cached custom scalar fields with a cache-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "cache-only" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses cached custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses cached and network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a network-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "network-only" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = + await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + } +); diff --git a/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx new file mode 100644 index 00000000000..9271b6e631f --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx @@ -0,0 +1,318 @@ +import { screen } 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 { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import type { QueryRef } from "@apollo/client/react"; +import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }), + }); + + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + skipNonTrackingRenders: true, + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + function ReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + using _disabledAct = disableActEnvironment(); + const user = userEvent.setup(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery"], + }); + + await user.click(screen.getByText("Load query")); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }), + }); + + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + skipNonTrackingRenders: true, + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + function ReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + using _disabledAct = disableActEnvironment(); + const user = userEvent.setup(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery"], + }); + + await user.click(screen.getByText("Load query")); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }).pipe(delay(20)); + }), + }); + + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + skipNonTrackingRenders: true, + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + function ReadQuery({ queryRef }: { queryRef: QueryRef }) { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + }> + {queryRef && } + + + ); + } + + using _disabledAct = disableActEnvironment(); + const user = userEvent.setup(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery"], + }); + + await user.click(screen.getByText("Load query")); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useLoadableQuery", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ + filter: { date: "2026-01-01" }, + }); +}); diff --git a/src/react/hooks/__tests__/useMutation/customScalars.test.tsx b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx new file mode 100644 index 00000000000..a7f5fd20736 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx @@ -0,0 +1,1109 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { delay, of } from "rxjs"; + +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { MockLink } from "@apollo/client/testing"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }).pipe(delay(20)); + }), + }); + + const mutation = gql` + mutation CreateEvent($date: Date!) { + createEvent(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ variables: { date: new Date(2026, 0, 1) } }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }).pipe(delay(20)); + }), + }); + + const mutation = gql` + mutation CreateEvent($date: Date!) { + createEvent @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ variables: { date: new Date(2026, 0, 1) } }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventInput: { + fields: { + date: "Date", + }, + }, + }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }).pipe(delay(20)); + }), + }); + + const mutation = gql` + mutation CreateEvent($input: EventInput!) { + createEvent(input: $input) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ variables: { input: { date: new Date(2026, 0, 1) } } }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ + input: { date: "2026-01-01" }, + }); +}); + +test("parses custom scalar fields with a network-only fetch policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ fetchPolicy: "network-only" }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = + await renderHookToSnapshotStream(() => useMutation(mutation), { + wrapper: createClientWrapper(client), + }); + + await takeSnapshot(); + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ fetchPolicy: "no-cache" }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + } +); + +test("parses parsed custom scalar fields in optimistic responses", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const fragment = gql` + fragment EventFragment on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + const promise = mutate({ + optimisticResponse: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + expect( + client.cache.readFragment({ + id: "Event:1", + fragment, + optimistic: true, + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses serialized custom scalar fields in optimistic responses", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const fragment = gql` + fragment EventFragment on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + const promise = mutate({ + optimisticResponse: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + expect( + client.cache.readFragment({ + id: "Event:1", + fragment, + optimistic: true, + }) + ).toStrictEqualTyped({ + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }); + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("passes parsed custom scalar fields to mutation updater callbacks", async () => { + const mutation: TypedDocumentNode<{ + createEvent: { __typename: "Event"; id: string; startDate: Date }; + }> = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + const query = gql` + query LastCreatedEvent { + lastCreatedEvent { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect( + mutate({ + update(cache, result) { + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + cache.writeQuery({ + query, + data: { + lastCreatedEvent: result.data!.createEvent, + }, + }); + }, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + lastCreatedEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields in queries triggered by refetchQueries", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + } + } + `; + let requestCount = 0; + const link = new MockLink([ + { + request: { query }, + maxUsageCount: 2, + delay: 20, + result: () => { + requestCount++; + + return { + data: { + event: { + __typename: "Event", + id: "1", + startDate: requestCount === 1 ? "2026-01-01" : "2026-03-03", + }, + }, + }; + }, + }, + { + request: { query: mutation }, + delay: 20, + result: { + data: { + createEvent: { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + }, + }, + }, + ]); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => ({ + query: useQuery(query), + mutation: useMutation(mutation), + }), + { wrapper: createClientWrapper(client) } + ); + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const { + mutation: [mutate], + } = getCurrentSnapshot(); + + await expect( + mutate({ + refetchQueries: [query], + awaitRefetchQueries: true, + }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + }, + }); + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + previousData: undefined, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 2, 3), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const { + query, + mutation: [, mutation], + } = await takeSnapshot(); + + expect(query).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 2, 3), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + + expect(mutation).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); + + expect(client.readQuery({ query })).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 2, 3), + }, + }); +}); diff --git a/src/react/hooks/__tests__/useQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx new file mode 100644 index 00000000000..a92bbd12abc --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx @@ -0,0 +1,698 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useQuery } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + variables: { + date: new Date(2026, 0, 1), + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + variables: { + date: new Date(2026, 0, 1), + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + date: new Date(2026, 0, 1), + }, + }); + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useQuery(query, { + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }); + + await expect(takeSnapshot).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("parses cached custom scalar fields with a cache-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-only" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses cached custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses cached and network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses network custom scalar fields with a network-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "network-only" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + await expect(takeSnapshot).not.toRerender(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } +); diff --git a/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx b/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx new file mode 100644 index 00000000000..3f5f3beffbe --- /dev/null +++ b/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx @@ -0,0 +1,447 @@ +import { screen } from "@testing-library/react"; +import { + createRenderStream, + disableActEnvironment, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import { userEvent } from "@testing-library/user-event"; +import { OperationTypeNode } from "graphql"; +import React, { Suspense } from "react"; +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { + createQueryPreloader, + useQueryRefHandlers, + useReadQuery, +} from "@apollo/client/react"; +import { MockSubscriptionLink } from "@apollo/client/testing"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables passed to refetch", async () => { + let requestVariables!: OperationVariables; + let refetchPromise!: ReturnType< + ReturnType["refetch"] + >; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }).pipe(delay(20)); + }), + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { + variables: { date: "2025-01-01" }, + }); + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + }); + + function ReadQuery() { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useQueryRefHandlers" }); + const { refetch } = useQueryRefHandlers(queryRef); + + return ( + <> + + }> + + + + ); + } + + const user = userEvent.setup(); + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useQueryRefHandlers", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await user.click(screen.getByText("Refetch")); + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useQueryRefHandlers", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables passed to fetchMore", async () => { + let requestVariables!: OperationVariables; + let fetchMorePromise!: ReturnType< + ReturnType["fetchMore"] + >; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { + event: { + __typename: "Event", + name: `Event on ${operation.variables.date}`, + }, + }, + }).pipe(delay(20)); + }), + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + const queryRef = createQueryPreloader(client)(query, { + variables: { date: "2025-01-01" }, + }); + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + }); + + function ReadQuery() { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useQueryRefHandlers" }); + const { fetchMore } = useQueryRefHandlers(queryRef); + + return ( + <> + + }> + + + + ); + } + + const user = userEvent.setup(); + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useQueryRefHandlers", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2025-01-01", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await user.click(screen.getByText("Fetch more")); + + await expect(fetchMorePromise).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useQueryRefHandlers", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Event on 2026-01-01", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables passed to subscribeToMore", async () => { + const subscriptionLink = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: ApolloLink.split( + ({ operationType }) => operationType === OperationTypeNode.SUBSCRIPTION, + subscriptionLink, + new ApolloLink(() => { + return of({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }).pipe(delay(20)); + }) + ), + }); + + const query = gql` + query Event { + event { + name + } + } + `; + const subscription = gql` + subscription EventUpdated($date: Date!) { + eventUpdated(date: $date) { + name + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + }); + + function ReadQuery() { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useQueryRefHandlers" }); + const { subscribeToMore } = useQueryRefHandlers(queryRef); + + return ( + <> + + }> + + + + ); + } + + const user = userEvent.setup(); + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + await expect(renderStream.takeRender()).resolves.toMatchObject({ + renderedComponents: ["useQueryRefHandlers", ""], + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await user.click(screen.getByText("Subscribe")); + + subscriptionLink.simulateResult({ + result: { + data: { + eventUpdated: { + __typename: "Event", + name: "Apollo Summit", + }, + }, + }, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "Apollo Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); + + expect(subscriptionLink.operation?.variables).toStrictEqualTyped({ + date: "2026-01-01", + }); +}); diff --git a/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx b/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx new file mode 100644 index 00000000000..5f9741aef45 --- /dev/null +++ b/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx @@ -0,0 +1,472 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { ApolloClient, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useSubscription } from "@apollo/client/react"; +import { MockSubscriptionLink } from "@apollo/client/testing"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("serializes scalar variables used in field arguments", async () => { + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link, + }); + + const subscription = gql` + subscription EventCreated($date: Date!) { + eventCreated(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSubscription(subscription, { + variables: { date: new Date(2026, 0, 1) }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); + expect(link.operation?.variables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link, + }); + + const subscription = gql` + subscription EventCreated($date: Date!) { + eventCreated @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSubscription(subscription, { + variables: { date: new Date(2026, 0, 1) }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); + expect(link.operation?.variables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + + const subscription = gql` + subscription EventCreated($filter: EventFilter!) { + eventCreated(filter: $filter) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSubscription(subscription, { + variables: { filter: { date: new Date(2026, 0, 1) } }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); + expect(link.operation?.variables).toStrictEqualTyped({ + filter: { date: "2026-01-01" }, + }); +}); + +test("parses custom scalar fields with a cache-only fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSubscription(subscription, { fetchPolicy: "cache-only" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields with a cache-first fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSubscription(subscription, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields with a network-only fetch policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSubscription(subscription, { fetchPolicy: "network-only" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useSubscription(subscription, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + error: undefined, + loading: true, + }); + + link.simulateResult( + { + result: { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }, + }, + true + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + error: undefined, + loading: false, + }); + + await expect(takeSnapshot).not.toRerender(); + } +); diff --git a/src/react/hooks/__tests__/useSuspenseFragment/customScalars.test.tsx b/src/react/hooks/__tests__/useSuspenseFragment/customScalars.test.tsx new file mode 100644 index 00000000000..affe4a8f410 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseFragment/customScalars.test.tsx @@ -0,0 +1,79 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; + +import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useSuspenseFragment } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +test("returns parsed custom scalar fields", async () => { + const fragment = gql` + fragment EventFields on Event { + id + startDate + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => + useSuspenseFragment({ + fragment, + from: { __typename: "Event", id: "1" }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); + + client.writeFragment({ + fragment, + data: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }); + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx new file mode 100644 index 00000000000..504ed0dc071 --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx @@ -0,0 +1,631 @@ +import { disableActEnvironment } from "@testing-library/react-render-stream"; +import { delay, of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { + createClientWrapper, + dateScalar, +} from "@apollo/client/testing/internal"; + +import { renderUseSuspenseQuery } from "./testUtils.js"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => + useSuspenseQuery(query, { + variables: { + date: new Date(2026, 0, 1), + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: [""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + }), + link, + }); + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => + useSuspenseQuery(query, { + variables: { + date: new Date(2026, 0, 1), + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: [""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const link = new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }); + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { + Date: dateScalar, + }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link, + }); + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => + useSuspenseQuery(query, { + variables: { + filter: { + date: new Date(2026, 0, 1), + }, + }, + }), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeRender()).resolves.toMatchObject({ + renderedComponents: [""], + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); + + expect(requestVariables).toStrictEqualTyped({ + filter: { + date: "2026-01-01", + }, + }); +}); + +test("parses cached custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-first fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "cache-first" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses cached and network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-02-02", + }, + }, + }).pipe(delay(20)) + ), + }); + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.loading, + error: undefined, + }); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses network custom scalar fields with a cache-and-network fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "cache-and-network" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses network custom scalar fields with a network-only fetch policy", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "network-only" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test.failing( + "parses custom scalar fields with a no-cache fetch policy", + async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { fetchPolicy: "no-cache" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + } +); diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx new file mode 100644 index 00000000000..8ea858fed3d --- /dev/null +++ b/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx @@ -0,0 +1,160 @@ +import { disableActEnvironment } from "@testing-library/react-render-stream"; +import { of } from "rxjs"; + +import type { OperationVariables } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { createQueryPreloader } from "@apollo/client/react"; +import { dateScalar } from "@apollo/client/testing/internal"; + +import { renderDefaultTestApp } from "./testUtils.js"; + +test("serializes scalar variables used in field arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($date: Date!) { + event(date: $date) { + name + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { + variables: { date: new Date(2026, 0, 1) }, + }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents, snapshot } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar variables used in directive arguments", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($date: Date!) { + event @on(date: $date) { + name + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { + variables: { date: new Date(2026, 0, 1) }, + }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents, snapshot } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); +}); + +test("serializes scalar fields in input object variables", async () => { + let requestVariables!: OperationVariables; + + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + inputObjects: { + EventFilter: { + fields: { + date: "Date", + }, + }, + }, + }), + link: new ApolloLink((operation) => { + requestVariables = operation.variables; + + return of({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + }); + }), + }); + + const query = gql` + query Event($filter: EventFilter!) { + event(filter: $filter) { + name + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { + variables: { filter: { date: new Date(2026, 0, 1) } }, + }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents, snapshot } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", "useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { event: { __typename: "Event", name: "GraphQL Summit" } }, + dataState: "complete", + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + expect(requestVariables).toStrictEqualTyped({ + filter: { date: "2026-01-01" }, + }); +}); diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx new file mode 100644 index 00000000000..f082a9cc770 --- /dev/null +++ b/src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx @@ -0,0 +1,70 @@ +import { + createRenderStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { ApolloClient, DataState } from "@apollo/client"; +import type { QueryRef } from "@apollo/client/react"; +import { useReadQuery } from "@apollo/client/react"; +import { createClientWrapper } from "@apollo/client/testing/internal"; + +export async function renderDefaultTestApp< + TData, + TStates extends DataState["dataState"] = "complete" | "streaming", +>({ + client, + queryRef, +}: { + client: ApolloClient; + queryRef: QueryRef; +}) { + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + error: null as Error | null, + }, + }); + + function ReadQueryHook() { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "" }); + return

Loading

; + } + + function ErrorFallback({ error }: { error: Error }) { + useTrackRenders({ name: "Error" }); + renderStream.mergeSnapshot({ error }); + + return null; + } + + function App() { + useTrackRenders({ name: "App" }); + + return ( + + }> + + + + ); + } + + const utils = await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + function rerender() { + return utils.rerender(); + } + + return { ...utils, rerender, renderStream }; +} diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index de686fc955d..31e2d83f706 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -42,3 +42,10 @@ export { wait } from "./wait.js"; export { friendListSchemaGraphQL17Alpha2 } from "./schemas/friendList.graphql17Alpha2.js"; export { friendListSchemaGraphQL17Alpha9 } from "./schemas/friendList.graphql17Alpha9.js"; + +export { + dateScalar, + dateTimeScalar, + jsonObjectScalar, + priceScalar, +} from "./scalars.js"; diff --git a/src/testing/internal/scalars.ts b/src/testing/internal/scalars.ts new file mode 100644 index 00000000000..e247bae3b7b --- /dev/null +++ b/src/testing/internal/scalars.ts @@ -0,0 +1,36 @@ +import { Scalar } from "@apollo/client/cache"; + +export const dateTimeScalar = new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), +}); + +// Like DateTime, but useful when needing to ensure the result isn't the same as +// calling toISOString, like JSON.stringify would do. +export const dateScalar = new Scalar({ + serialize: (date) => + `${date.getFullYear()}-${(date.getMonth() + 1) + .toString() + .padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`, + parse: (value) => { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day); + }, +}); + +// Used for testing primitive -> primitive tests +export const priceScalar = new Scalar({ + serialize: (dollars) => Math.round(parseFloat(dollars) * 100), + parse: (cents) => `${(cents / 100).toFixed(2)}`, + is: (value) => typeof value === "string", +}); + +// Used for testing object-based scalar +export const jsonObjectScalar = new Scalar< + Record, + Map +>({ + serialize: (value) => Object.fromEntries(value), + parse: (value) => new Map(Object.entries(value)), + is: (value) => value instanceof Map, +}); diff --git a/src/utilities/internal/getUnwrappedType.ts b/src/utilities/internal/getUnwrappedType.ts new file mode 100644 index 00000000000..2ff2aee486a --- /dev/null +++ b/src/utilities/internal/getUnwrappedType.ts @@ -0,0 +1,14 @@ +import type { TypeNode } from "graphql"; +import { Kind } from "graphql"; + +/** @internal */ +export function getUnwrappedType(node: TypeNode) { + switch (node.kind) { + case Kind.NAMED_TYPE: + return node.name.value; + case Kind.LIST_TYPE: + return getUnwrappedType(node.type); + case Kind.NON_NULL_TYPE: + return getUnwrappedType(node.type); + } +} diff --git a/src/utilities/internal/index.ts b/src/utilities/internal/index.ts index 2c85f3ab21e..074f821632b 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -5,6 +5,7 @@ export type { FragmentMap } from "./types/FragmentMap.js"; export type { FragmentMapFunction } from "./types/FragmentMapFunction.js"; export type { FulfilledPromise } from "./types/FulfilledPromise.js"; export type { IsAny } from "./types/IsAny.js"; +export type { IsLooselyEqual } from "./types/IsLooselyEqual.js"; export type { NoInfer } from "./types/NoInfer.js"; export type { PendingPromise } from "./types/PendingPromise.js"; export type { Prettify } from "./types/Prettify.js"; @@ -44,6 +45,7 @@ export { getOperationDefinition } from "./getOperationDefinition.js"; export { getOperationName } from "./getOperationName.js"; export { getQueryDefinition } from "./getQueryDefinition.js"; export { getStoreKeyName } from "./getStoreKeyName.js"; +export { getUnwrappedType } from "./getUnwrappedType.js"; export { graphQLResultHasError } from "./graphQLResultHasError.js"; export { hasDirectives } from "./hasDirectives.js"; export { hasForcedResolvers } from "./hasForcedResolvers.js"; diff --git a/src/utilities/internal/types/IsLooselyEqual.ts b/src/utilities/internal/types/IsLooselyEqual.ts new file mode 100644 index 00000000000..e4f80464d25 --- /dev/null +++ b/src/utilities/internal/types/IsLooselyEqual.ts @@ -0,0 +1,10 @@ +/** + * Compare two types to see if they are loosely equal to each other without + * checking structural integrity. + */ +export type IsLooselyEqual = + [A] extends [B] ? + [B] extends [A] ? + true + : false + : false;