From d080f1114541475842335dfc77e431f455d45de7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Jun 2026 10:03:47 -0600 Subject: [PATCH 1/6] [Custom scalars] Add custom scalars plumbing to `InMemoryCache` and `ApolloCache` (#13252) _Part of the custom scalars work: https://github.com/apollographql/apollo-client/issues/13227_ Adds the plumbing needed for types in `ApolloCache` as well as `InMemoryCache` to handle custom scalars. Implements the `getScalar` method that will be used by this feature to convert a raw value to a scalar. * **New Features** * Added custom GraphQL scalar support to InMemoryCache with type-safe configuration for parse, serialize, type-guard, and devtools integration. * Added `getScalar()` method to retrieve configured scalar instances by name. * **Documentation** * Added guides for declaring and configuring custom scalars with usage examples. --- .api-reports/api-report-cache.api.md | 90 ++++++- .api-reports/api-report-core.api.md | 3 + .../api-report-utilities_internal.api.md | 7 + .api-reports/api-report.api.md | 105 ++++++-- .changeset/eighty-files-sort.md | 36 +++ .changeset/wet-experts-end.md | 5 + .../all-any/differentTypes/index.ts | 211 ++++++++++++++++ .../all-any/differentTypes/tsconfig.json | 11 + .../customScalars/all-any/empty/index.ts | 154 ++++++++++++ .../customScalars/all-any/empty/tsconfig.json | 11 + .../all-any/matchingTypes/index.ts | 230 +++++++++++++++++ .../all-any/matchingTypes/tsconfig.json | 11 + .../customScalars/all-any/mixed/index.ts | 130 ++++++++++ .../customScalars/all-any/mixed/tsconfig.json | 11 + .../all-structured/differentTypes/index.ts | 34 +++ .../differentTypes/tsconfig.json | 11 + .../all-structured/empty/index.ts | 152 ++++++++++++ .../all-structured/empty/tsconfig.json | 11 + .../all-structured/matchingTypes/index.ts | 115 +++++++++ .../matchingTypes/tsconfig.json | 11 + .../all-structured/mixed/index.ts | 40 +++ .../all-structured/mixed/tsconfig.json | 11 + .../all-unknown/differentTypes/index.ts | 212 ++++++++++++++++ .../all-unknown/differentTypes/tsconfig.json | 11 + .../customScalars/all-unknown/empty/index.ts | 158 ++++++++++++ .../all-unknown/empty/tsconfig.json | 11 + .../all-unknown/matchingTypes/index.ts | 232 ++++++++++++++++++ .../all-unknown/matchingTypes/tsconfig.json | 11 + .../customScalars/all-unknown/mixed/index.ts | 131 ++++++++++ .../all-unknown/mixed/tsconfig.json | 11 + .../customScalars/differentTypes/index.ts | 173 +++++++++++++ .../differentTypes/tsconfig.json | 11 + .../type-tests/customScalars/empty/index.ts | 22 ++ .../customScalars/empty/tsconfig.json | 11 + .../customScalars/matchingTypes/index.ts | 184 ++++++++++++++ .../customScalars/matchingTypes/tsconfig.json | 11 + .../type-tests/customScalars/mixed/index.ts | 120 +++++++++ .../customScalars/mixed/tsconfig.json | 11 + integration-tests/type-tests/tsconfig.json | 48 ++++ src/__tests__/__snapshots__/exports.ts.snap | 3 + src/cache/core/Scalar.ts | 45 ++++ src/cache/core/__tests__/cache.ts | 28 ++- src/cache/core/cache.ts | 19 ++ src/cache/index.ts | 1 + src/cache/inmemory/__tests__/scalars.ts | 94 +++++++ src/cache/inmemory/inMemoryCache.ts | 59 ++++- src/cache/inmemory/types.ts | 14 +- src/core/index.ts | 1 + src/declarations.d.ts | 4 + src/utilities/internal/index.ts | 1 + .../internal/types/IsLooselyEqual.ts | 10 + 51 files changed, 3017 insertions(+), 30 deletions(-) create mode 100644 .changeset/eighty-files-sort.md create mode 100644 .changeset/wet-experts-end.md create mode 100644 integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-any/differentTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-any/empty/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-any/empty/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-any/matchingTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-any/mixed/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-any/mixed/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-structured/differentTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-structured/empty/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-structured/empty/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-structured/matchingTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-structured/mixed/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-structured/mixed/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-unknown/differentTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-unknown/empty/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-unknown/empty/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-unknown/matchingTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts create mode 100644 integration-tests/type-tests/customScalars/all-unknown/mixed/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/differentTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/differentTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/empty/index.ts create mode 100644 integration-tests/type-tests/customScalars/empty/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/matchingTypes/index.ts create mode 100644 integration-tests/type-tests/customScalars/matchingTypes/tsconfig.json create mode 100644 integration-tests/type-tests/customScalars/mixed/index.ts create mode 100644 integration-tests/type-tests/customScalars/mixed/tsconfig.json create mode 100644 src/cache/core/Scalar.ts create mode 100644 src/cache/inmemory/__tests__/scalars.ts create mode 100644 src/utilities/internal/types/IsLooselyEqual.ts diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index ca4bd747f58..6ac94d268cf 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -23,12 +23,14 @@ import { getInMemoryCacheMemoryInternals } from '@apollo/client/utilities/intern 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 +48,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 +102,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; @@ -553,9 +565,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 +615,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; @@ -618,16 +655,18 @@ export class InMemoryCache extends ApolloCache { } // @public (undocumented) -export interface InMemoryCacheConfig extends ApolloReducerConfig { - // (undocumented) - fragments?: FragmentRegistryAPI; - // (undocumented) - possibleTypes?: PossibleTypesMap; - // (undocumented) +export type InMemoryCacheConfig = ApolloReducerConfig & { resultCaching?: boolean; - // (undocumented) + possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; -} + fragments?: FragmentRegistryAPI; +} & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { + scalars?: Record; +} : { + scalars?: InMemoryCache.ScalarsOption; +} : { + scalars: InMemoryCache.ScalarsOption; +}); // @public (undocumented) interface InvalidateModifier { @@ -664,6 +703,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); @@ -931,6 +973,34 @@ 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) + is(value: TSerialized | TParsed): value is TParsed; + // (undocumented) + parse(value: TSerialized): TParsed; + // (undocumented) + serialize(value: TParsed): TSerialized; +} + // @public (undocumented) type StorageType = Record; @@ -1015,7 +1085,7 @@ interface WriteContext extends ReadMergeModifyContext { // // 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/types.ts:136: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 e80fcd5f985..c9593338feb 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -87,6 +87,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'; @@ -1244,6 +1245,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..57cf096758d 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -370,6 +370,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 4e07b5ba563..1940fc2909b 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -41,9 +41,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 +99,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; @@ -1557,9 +1567,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 +1620,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; @@ -1624,18 +1660,18 @@ 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) - possibleTypes?: PossibleTypesMap; - // (undocumented) +export type InMemoryCacheConfig = ApolloReducerConfig & { resultCaching?: boolean; - // (undocumented) + possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; -} + fragments?: FragmentRegistryAPI; +} & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { + scalars?: Record; +} : { + scalars?: InMemoryCache.ScalarsOption; +} : { + scalars: InMemoryCache.ScalarsOption; +}); // Warning: (ae-forgotten-export) The symbol "RefetchQueriesIncludeShorthand" needs to be exported by the entry point index.d.ts // @@ -1676,6 +1712,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 +1749,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 +1932,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; @@ -2753,6 +2799,34 @@ 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) + is(value: TSerialized | TParsed): value is TParsed; + // (undocumented) + parse(value: TSerialized): TParsed; + // (undocumented) + serialize(value: TParsed): TSerialized; +} + // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -3068,11 +3142,12 @@ 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/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: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/inmemory/types.ts:136:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts +// src/cache/inmemory/types.ts:143: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/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/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/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..afb3e6c47e4 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts @@ -0,0 +1,211 @@ +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 + >(); +}); 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..f09eb54f6fe --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/empty/index.ts @@ -0,0 +1,154 @@ +import { ApolloCache, 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 + >(); +}); 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..62a2105d02e --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts @@ -0,0 +1,230 @@ +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 + >(); +}); 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..977afd389e3 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-any/mixed/index.ts @@ -0,0 +1,130 @@ +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 + >(); +}); 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..188638ba0a1 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts @@ -0,0 +1,34 @@ +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), + }), + }, + }); +}); 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..5ea29f27810 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/empty/index.ts @@ -0,0 +1,152 @@ +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 + >(); +}); 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..f830925ea7c --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts @@ -0,0 +1,115 @@ +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 + >(); +}); 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..161f0878d8d --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts @@ -0,0 +1,40 @@ +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, + }), + }, + }); +}); 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..bb2f55a7548 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts @@ -0,0 +1,212 @@ +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 + >(); +}); 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..e9cef3955ef --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts @@ -0,0 +1,158 @@ +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 + >(); +}); 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..c116b52acbe --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts @@ -0,0 +1,232 @@ +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 + >(); +}); 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..3253084b766 --- /dev/null +++ b/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts @@ -0,0 +1,131 @@ +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 + >(); +}); 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..21be6356105 --- /dev/null +++ b/integration-tests/type-tests/customScalars/differentTypes/index.ts @@ -0,0 +1,173 @@ +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/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("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"); +}); 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..629ba883ee3 --- /dev/null +++ b/integration-tests/type-tests/customScalars/empty/index.ts @@ -0,0 +1,22 @@ +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"); +}); 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..947aab4d028 --- /dev/null +++ b/integration-tests/type-tests/customScalars/matchingTypes/index.ts @@ -0,0 +1,184 @@ +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"); +}); 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..dc4a9b91ec7 --- /dev/null +++ b/integration-tests/type-tests/customScalars/mixed/index.ts @@ -0,0 +1,120 @@ +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"); +}); 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/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d18aeac8a9d..a4031b259e8 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", diff --git a/src/cache/core/Scalar.ts b/src/cache/core/Scalar.ts new file mode 100644 index 00000000000..0b32fb2805a --- /dev/null +++ b/src/cache/core/Scalar.ts @@ -0,0 +1,45 @@ +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; + + 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..de1ee0f88c5 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,14 @@ export abstract class ApolloCache { return null; } + // Custom scalars API + + public getScalar( + key: TKey + ): ApolloCache.GetScalarType | undefined { + return; + } + // Local state API /** diff --git a/src/cache/index.ts b/src/cache/index.ts index 20ac58494a2..bbe9141ff68 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, diff --git a/src/cache/inmemory/__tests__/scalars.ts b/src/cache/inmemory/__tests__/scalars.ts new file mode 100644 index 00000000000..eac31712a6e --- /dev/null +++ b/src/cache/inmemory/__tests__/scalars.ts @@ -0,0 +1,94 @@ +import { InMemoryCache, Scalar } from "@apollo/client/cache"; + +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("2024-01-01T00:00:00.000Z"))).toBe( + "2024-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("2024-01-01T00:00:00.000Z")).toEqual( + new Date("2024-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("2024-01-01T00:00:00.000Z"))).toBe(true); + expect(scalar.is("2024-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("2024-01-01T00:00:00.000Z"))).toBe(true); + expect(scalar.is(new Date("invalid"))).toBe(false); +}); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index f5ad7c518c4..7c0d08188bd 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -22,11 +22,16 @@ import { print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; +import type { + IsLooselyEqual, + RemoveIndexSignature, +} from "@apollo/client/utilities/internal"; import { getInMemoryCacheMemoryInternals } 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"; @@ -42,6 +47,38 @@ type BroadcastOptions = Pick< "optimistic" | "onWatchUpdated" >; +type KnownScalars = RemoveIndexSignature; + +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 +107,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 +197,20 @@ 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; + } + public restore(data: NormalizedCacheObject): this { this.init(); // Since calling this.init() discards/replaces the entire StoreReader, along diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 1b309fc9d36..de00790df0b 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -18,6 +18,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 +136,21 @@ export type ApolloReducerConfig = { dataIdFromObject?: KeyFieldsFunction; }; -export interface InMemoryCacheConfig extends ApolloReducerConfig { +export type InMemoryCacheConfig = ApolloReducerConfig & { resultCaching?: boolean; possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; fragments?: FragmentRegistryAPI; -} +} & ({} 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; diff --git a/src/core/index.ts b/src/core/index.ts index 6e2d78883d8..247a63efee8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -104,6 +104,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/utilities/internal/index.ts b/src/utilities/internal/index.ts index 2c85f3ab21e..204d6b55a84 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"; 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; From 60319870f8f38463ba94c798c91752bf8a15eb91 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Jun 2026 10:31:44 -0600 Subject: [PATCH 2/6] [Custom scalars] Handle scalar parsing during cache reads and writes (#13259) _Part of the custom scalars work: https://github.com/apollographql/apollo-client/issues/13227_ Adds scalar value serialization for scalar fields during cache writes. This allows users to provide either the parsed or serialized value to the cache and have it work as expected. --- .api-reports/api-report-cache.api.md | 13 +- .api-reports/api-report.api.md | 17 +- .changeset/strong-shoes-sell.md | 30 + .../all-any/differentTypes/index.ts | 26 + .../customScalars/all-any/empty/index.ts | 22 +- .../all-any/matchingTypes/index.ts | 20 + .../customScalars/all-any/mixed/index.ts | 26 + .../all-structured/differentTypes/index.ts | 27 + .../all-structured/empty/index.ts | 20 + .../all-structured/matchingTypes/index.ts | 20 + .../all-structured/mixed/index.ts | 31 + .../all-unknown/differentTypes/index.ts | 26 + .../customScalars/all-unknown/empty/index.ts | 20 + .../all-unknown/matchingTypes/index.ts | 20 + .../customScalars/all-unknown/mixed/index.ts | 26 + .../customScalars/differentTypes/index.ts | 24 + .../type-tests/customScalars/empty/index.ts | 15 + .../customScalars/matchingTypes/index.ts | 21 + .../type-tests/customScalars/mixed/index.ts | 27 + src/cache/inmemory/__tests__/scalars.ts | 3886 ++++++++++++++++- src/cache/inmemory/entityStore.ts | 77 +- src/cache/inmemory/inMemoryCache.ts | 13 +- src/cache/inmemory/policies.ts | 20 +- src/cache/inmemory/readFromStore.ts | 19 + src/cache/inmemory/types.ts | 12 +- 25 files changed, 4430 insertions(+), 28 deletions(-) create mode 100644 .changeset/strong-shoes-sell.md diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 6ac94d268cf..7c5159fd39e 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -500,6 +500,7 @@ export type FieldPolicy; merge?: FieldMergeFunction | boolean; + scalar?: ScalarNames; }; // @public (undocumented) @@ -869,6 +870,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) @@ -1001,6 +1004,9 @@ export class Scalar { serialize(value: TParsed): TSerialized; } +// @public (undocumented) +type ScalarNames = keyof KnownScalars | (string extends keyof ApolloCache.Scalars ? string & {} : never); + // @public (undocumented) type StorageType = Record; @@ -1083,9 +1089,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:136: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.api.md b/.api-reports/api-report.api.md index 1940fc2909b..d85bc6f2ef6 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -1331,6 +1331,7 @@ export type FieldPolicy; merge?: FieldMergeFunction | boolean; + scalar?: ScalarNames; }; // @public (undocumented) @@ -2367,6 +2368,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) @@ -2827,6 +2830,9 @@ export class Scalar { 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) @@ -3143,11 +3149,12 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // // 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: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:136:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts -// src/cache/inmemory/types.ts:143:3 - (ae-forgotten-export) The symbol "FragmentRegistryAPI" 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:146: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/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/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts index afb3e6c47e4..02e7650e904 100644 --- a/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts @@ -209,3 +209,29 @@ test("InMemoryCache.getScalar returns the resolved scalar for a declared scalar" 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/empty/index.ts b/integration-tests/type-tests/customScalars/all-any/empty/index.ts index f09eb54f6fe..91011b42af7 100644 --- a/integration-tests/type-tests/customScalars/all-any/empty/index.ts +++ b/integration-tests/type-tests/customScalars/all-any/empty/index.ts @@ -1,4 +1,4 @@ -import { ApolloCache, InMemoryCache, Scalar } from "@apollo/client/cache"; +import { InMemoryCache, Scalar } from "@apollo/client/cache"; import { expectTypeOf } from "expect-type"; declare function test(name: string, fn: () => void): void; @@ -152,3 +152,23 @@ test("getScalar returns the resolved scalar or undefined", () => { 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/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts index 62a2105d02e..b75974b741c 100644 --- a/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts @@ -228,3 +228,23 @@ test("getScalar returns the resolved scalar or undefined", () => { 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/mixed/index.ts b/integration-tests/type-tests/customScalars/all-any/mixed/index.ts index 977afd389e3..c523b7dce31 100644 --- a/integration-tests/type-tests/customScalars/all-any/mixed/index.ts +++ b/integration-tests/type-tests/customScalars/all-any/mixed/index.ts @@ -128,3 +128,29 @@ test("getScalar resolves each scalar according to its declaration", () => { 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-structured/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts index 188638ba0a1..8fdf281969a 100644 --- a/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts @@ -32,3 +32,30 @@ test("a transforming scalar that conflicts with the index cannot be configured", }, }); }); + +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/empty/index.ts b/integration-tests/type-tests/customScalars/all-structured/empty/index.ts index 5ea29f27810..ef804722237 100644 --- a/integration-tests/type-tests/customScalars/all-structured/empty/index.ts +++ b/integration-tests/type-tests/customScalars/all-structured/empty/index.ts @@ -150,3 +150,23 @@ test("getScalar returns the resolved scalar or undefined", () => { 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/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts index f830925ea7c..51adf34b6c0 100644 --- a/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts @@ -113,3 +113,23 @@ test("getScalar resolves each scalar according to its declaration", () => { 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/mixed/index.ts b/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts index 161f0878d8d..c8def17feb5 100644 --- a/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts +++ b/integration-tests/type-tests/customScalars/all-structured/mixed/index.ts @@ -38,3 +38,34 @@ test("a transforming scalar conflicting with the index blocks configuration", () }, }); }); + +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-unknown/differentTypes/index.ts b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts index bb2f55a7548..f31160140e0 100644 --- a/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts @@ -210,3 +210,29 @@ test("InMemoryCache.getScalar returns the resolved scalar for a declared scalar" 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/empty/index.ts b/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts index e9cef3955ef..8f1c9eac21b 100644 --- a/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts +++ b/integration-tests/type-tests/customScalars/all-unknown/empty/index.ts @@ -156,3 +156,23 @@ test("getScalar returns the resolved scalar or undefined", () => { 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/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts index c116b52acbe..70fa39278da 100644 --- a/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts +++ b/integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts @@ -230,3 +230,23 @@ test("getScalar returns the resolved scalar or undefined", () => { 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/mixed/index.ts b/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts index 3253084b766..c6e87146a4f 100644 --- a/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts +++ b/integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts @@ -129,3 +129,29 @@ test("getScalar resolves each scalar according to its declaration", () => { 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/differentTypes/index.ts b/integration-tests/type-tests/customScalars/differentTypes/index.ts index 21be6356105..865ee99690e 100644 --- a/integration-tests/type-tests/customScalars/differentTypes/index.ts +++ b/integration-tests/type-tests/customScalars/differentTypes/index.ts @@ -171,3 +171,27 @@ test("InMemoryCache.getScalar returns the resolved scalar for a declared 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/empty/index.ts b/integration-tests/type-tests/customScalars/empty/index.ts index 629ba883ee3..68d2d110aa4 100644 --- a/integration-tests/type-tests/customScalars/empty/index.ts +++ b/integration-tests/type-tests/customScalars/empty/index.ts @@ -20,3 +20,18 @@ test("getScalar cannot be called without a declared scalar", () => { // @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/matchingTypes/index.ts b/integration-tests/type-tests/customScalars/matchingTypes/index.ts index 947aab4d028..0ae82d270ef 100644 --- a/integration-tests/type-tests/customScalars/matchingTypes/index.ts +++ b/integration-tests/type-tests/customScalars/matchingTypes/index.ts @@ -182,3 +182,24 @@ test("getScalar returns the resolved scalar or 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/mixed/index.ts b/integration-tests/type-tests/customScalars/mixed/index.ts index dc4a9b91ec7..8b39d3060ca 100644 --- a/integration-tests/type-tests/customScalars/mixed/index.ts +++ b/integration-tests/type-tests/customScalars/mixed/index.ts @@ -118,3 +118,30 @@ test("getScalar resolves each scalar according to its declaration", () => { // @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/src/cache/inmemory/__tests__/scalars.ts b/src/cache/inmemory/__tests__/scalars.ts index eac31712a6e..72887a142b6 100644 --- a/src/cache/inmemory/__tests__/scalars.ts +++ b/src/cache/inmemory/__tests__/scalars.ts @@ -1,4 +1,30 @@ +import type { TypedDocumentNode } from "@apollo/client"; +import { gql } from "@apollo/client"; import { InMemoryCache, Scalar } from "@apollo/client/cache"; +import { + ObservableStream, + spyOnConsole, +} from "@apollo/client/testing/internal"; + +const dateTimeScalar = new Scalar({ + serialize: (value) => value.toISOString(), + parse: (value) => new Date(value), +}); + +const priceScalar = new Scalar({ + serialize: (dollars) => Math.round(parseFloat(dollars) * 100), + parse: (cents) => `${(cents / 100).toFixed(2)}`, + is: (value) => typeof value === "string", +}); + +const jsonObjectScalar = new Scalar< + Record, + Map +>({ + serialize: (value) => Object.fromEntries(value), + parse: (value) => new Map(Object.entries(value)), + is: (value) => value instanceof Map, +}); test("getScalar returns a scalar object for a configured scalar", () => { const cache = new InMemoryCache({ @@ -38,8 +64,8 @@ test("serialize uses the configured serialize function", () => { const scalar = cache.getScalar("DateTime")!; - expect(scalar.serialize(new Date("2024-01-01T00:00:00.000Z"))).toBe( - "2024-01-01T00:00:00.000Z" + expect(scalar.serialize(new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2026-01-01T00:00:00.000Z" ); }); @@ -55,8 +81,8 @@ test("parse uses the configured parse function", () => { const scalar = cache.getScalar("DateTime")!; - expect(scalar.parse("2024-01-01T00:00:00.000Z")).toEqual( - new Date("2024-01-01T00:00:00.000Z") + expect(scalar.parse("2026-01-01T00:00:00.000Z")).toEqual( + new Date("2026-01-01T00:00:00.000Z") ); }); @@ -72,8 +98,8 @@ test("is defaults to a non-null object check when not configured", () => { const scalar = cache.getScalar("DateTime")!; - expect(scalar.is(new Date("2024-01-01T00:00:00.000Z"))).toBe(true); - expect(scalar.is("2024-01-01T00:00:00.000Z")).toBe(false); + 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", () => { @@ -89,6 +115,3852 @@ test("is uses the configured type guard when configured", () => { const scalar = cache.getScalar("DateTime")!; - expect(scalar.is(new Date("2024-01-01T00:00:00.000Z"))).toBe(true); + 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/entityStore.ts b/src/cache/inmemory/entityStore.ts index ce139e603f9..b06f9efd8c0 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) { + 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 7c0d08188bd..dc8b63fc806 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -22,10 +22,7 @@ import { print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import type { - IsLooselyEqual, - RemoveIndexSignature, -} from "@apollo/client/utilities/internal"; +import type { IsLooselyEqual } from "@apollo/client/utilities/internal"; import { getInMemoryCacheMemoryInternals } from "@apollo/client/utilities/internal"; import { invariant } from "@apollo/client/utilities/invariant"; @@ -39,7 +36,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< @@ -47,8 +48,6 @@ type BroadcastOptions = Pick< "optimistic" | "onWatchUpdated" >; -type KnownScalars = RemoveIndexSignature; - export declare namespace InMemoryCache { export type ScalarsOption = { [ScalarName in keyof KnownScalars as IsLooselyEqual< 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 de00790df0b..3309e99f1f4 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, @@ -171,3 +174,8 @@ export interface ReadMergeModifyContext { varString?: string; extensions?: ExtensionsWithStreamInfo; } + +export type KnownScalars = RemoveIndexSignature; +export type ScalarNames = + | keyof KnownScalars + | (string extends keyof ApolloCache.Scalars ? string & {} : never); From 2919413ca7ac72980dc4587f909fc248e4ccc3b7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 10 Jun 2026 22:16:17 -0600 Subject: [PATCH 3/6] Fix issue with build (#13272) Fixes the build issue on the custom scalars feature branch. --- .api-reports/api-report-core.api.md | 2 +- .api-reports/api-report.api.md | 2 +- src/core/QueryManager.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index c9593338feb..3d354499f5f 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -1001,7 +1001,7 @@ class QueryManager { // (undocumented) broadcastQueries(): void; // (undocumented) - get cache(): ApolloCache; + get cache(): Cache_2.Implementation; // (undocumented) clearStore(options?: Cache_2.ResetOptions): Promise; // (undocumented) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index d85bc6f2ef6..b7ee5e36e19 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -2467,7 +2467,7 @@ class QueryManager { // (undocumented) broadcastQueries(): void; // (undocumented) - get cache(): ApolloCache; + get cache(): Cache_2.Implementation; // (undocumented) clearStore(options?: Cache_2.ResetOptions): Promise; // (undocumented) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 334a2824ecf..70f166be3da 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; } From 7b10078f4bcd8d82890ca438bf7355677fe2f841 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 17 Jun 2026 15:31:13 -0400 Subject: [PATCH 4/6] Add a `fromGraphQLScalarType` helper (#13274) Adds a new `Scalar.fromGraphQLScalarType` helper that is used to create a `Scalar` instance from an existing `GraphQLScalarType` instance from the `graphql` package. This makes it easier to use scalar definitions from packages such as [`graphql-scalars`](https://the-guild.dev/graphql/scalars) --- .api-reports/api-report-cache.api.md | 3 + .api-reports/api-report.api.md | 3 + .changeset/cold-comics-add.md | 18 ++ .../customScalars/differentTypes/index.ts | 30 ++++ src/cache/core/Scalar.ts | 13 ++ src/cache/inmemory/__tests__/scalars.ts | 164 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 .changeset/cold-comics-add.md diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 7c5159fd39e..fcec9d32282 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -20,6 +20,7 @@ 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'; @@ -997,6 +998,8 @@ export class Scalar { // (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; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index b7ee5e36e19..1baa2a075d7 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'; @@ -2823,6 +2824,8 @@ export class Scalar { // (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; 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/integration-tests/type-tests/customScalars/differentTypes/index.ts b/integration-tests/type-tests/customScalars/differentTypes/index.ts index 865ee99690e..a8c6ec70bea 100644 --- a/integration-tests/type-tests/customScalars/differentTypes/index.ts +++ b/integration-tests/type-tests/customScalars/differentTypes/index.ts @@ -1,5 +1,6 @@ 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; @@ -63,6 +64,35 @@ test("serialize receives the parsed type and parse receives the serialized type" }); }); +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: { diff --git a/src/cache/core/Scalar.ts b/src/cache/core/Scalar.ts index 0b32fb2805a..e8e852ccc73 100644 --- a/src/cache/core/Scalar.ts +++ b/src/cache/core/Scalar.ts @@ -1,3 +1,5 @@ +import type { GraphQLScalarType } from "graphql"; + import type { NoInfer } from "@apollo/client/utilities/internal"; export declare namespace Scalar { @@ -15,6 +17,17 @@ export declare namespace Scalar { 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; } diff --git a/src/cache/inmemory/__tests__/scalars.ts b/src/cache/inmemory/__tests__/scalars.ts index 72887a142b6..761ce813091 100644 --- a/src/cache/inmemory/__tests__/scalars.ts +++ b/src/cache/inmemory/__tests__/scalars.ts @@ -1,3 +1,5 @@ +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"; @@ -6,6 +8,8 @@ import { spyOnConsole, } from "@apollo/client/testing/internal"; +const IS_GRAPHQL_17 = graphqlVersion.startsWith("17"); + const dateTimeScalar = new Scalar({ serialize: (value) => value.toISOString(), parse: (value) => new Date(value), @@ -26,6 +30,166 @@ const jsonObjectScalar = new Scalar< is: (value) => value instanceof Map, }); +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: { From 0886de19ed67ca24bbcc075dcf5a94ba01589902 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 29 Jun 2026 20:03:43 -0400 Subject: [PATCH 5/6] [Custom scalars] Automatic variable serialization (#13273) _Part of the custom scalars work: https://github.com/apollographql/apollo-client/issues/13227_ Adds automatic variable serialization for custom scalars. > [!NOTE] > This PR does not support the fragment APIs (e.g. `writeFragment`, `readFragment`, `watchFragment`. Fragment definitions don't have variable definitions and thus cannot be serialized. A followup PR will be opened to address this in some form. ## Summary by CodeRabbit * **New Features** * Automatic serialization of custom scalar values in GraphQL operation variables across queries, mutations, subscriptions, and cache operations * New `inputObjects` configuration option for InMemoryCache to specify scalar field locations within complex input object types --- .api-reports/api-report-cache.api.md | 17 + .api-reports/api-report-core.api.md | 6 + .../api-report-utilities_internal.api.md | 4 + .api-reports/api-report.api.md | 19 +- .changeset/thin-tips-fold.md | 46 + .circleci/config.yml | 3 +- config/jest.config.ts | 2 + package.json | 4 +- src/__tests__/__snapshots__/exports.ts.snap | 5 + src/cache/core/cache.ts | 29 + src/cache/index.ts | 2 + .../cache.readQuery/customScalars.test.ts | 152 ++ .../cache.writeQuery/customScalars.test.ts | 138 ++ src/cache/inmemory/__tests__/scalars.ts | 23 +- .../__tests__/variablesSerialization.ts | 1412 +++++++++++++++++ src/cache/inmemory/entityStore.ts | 2 +- src/cache/inmemory/inMemoryCache.ts | 157 +- src/cache/inmemory/types.ts | 9 + src/core/QueryManager.ts | 3 +- .../client.mutate/customScalars.test.ts | 160 ++ .../client.query/customScalars.test.ts | 207 +++ .../client.readQuery/customScalars.test.ts | 120 ++ .../customScalars.test.ts | 91 ++ .../client.subscribe/customScalars.test.ts | 172 ++ .../client.watchQuery/customScalars.test.ts | 498 ++++++ .../client.writeQuery/customScalars.test.ts | 123 ++ src/core/index.ts | 2 + .../useBackgroundQuery/customScalars.test.tsx | 192 +++ .../useLazyQuery/customScalars.test.tsx | 349 ++++ .../useLoadableQuery/customScalars.test.tsx | 318 ++++ .../useMutation/customScalars.test.tsx | 293 ++++ .../__tests__/useQuery/customScalars.test.tsx | 241 +++ .../customScalars.test.tsx | 447 ++++++ .../useSubscription/customScalars.test.tsx | 205 +++ .../useSuspenseQuery/customScalars.test.tsx | 218 +++ .../customScalars.test.tsx | 160 ++ .../createQueryPreloader/testUtils.tsx | 70 + src/testing/internal/index.ts | 7 + src/testing/internal/scalars.ts | 36 + src/utilities/internal/getUnwrappedType.ts | 14 + src/utilities/internal/index.ts | 1 + 41 files changed, 5928 insertions(+), 29 deletions(-) create mode 100644 .changeset/thin-tips-fold.md create mode 100644 src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts create mode 100644 src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts create mode 100644 src/cache/inmemory/__tests__/variablesSerialization.ts create mode 100644 src/core/__tests__/client.mutate/customScalars.test.ts create mode 100644 src/core/__tests__/client.query/customScalars.test.ts create mode 100644 src/core/__tests__/client.readQuery/customScalars.test.ts create mode 100644 src/core/__tests__/client.refetchQueries/customScalars.test.ts create mode 100644 src/core/__tests__/client.subscribe/customScalars.test.ts create mode 100644 src/core/__tests__/client.watchQuery/customScalars.test.ts create mode 100644 src/core/__tests__/client.writeQuery/customScalars.test.ts create mode 100644 src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useMutation/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useQuery/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useSubscription/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx create mode 100644 src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx create mode 100644 src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx create mode 100644 src/testing/internal/scalars.ts create mode 100644 src/utilities/internal/getUnwrappedType.ts diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index fcec9d32282..860d25d2985 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -130,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) @@ -648,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) @@ -662,6 +666,7 @@ export type InMemoryCacheConfig = ApolloReducerConfig & { possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; fragments?: FragmentRegistryAPI; + inputObjects?: InputObjectsOption; } & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { scalars?: Record; } : { @@ -670,6 +675,18 @@ export type InMemoryCacheConfig = ApolloReducerConfig & { scalars: InMemoryCache.ScalarsOption; }); +// @public (undocumented) +export interface InputObjectConfig { + // (undocumented) + fields: Record; +} + +// @public (undocumented) +export interface InputObjectsOption { + // (undocumented) + [inputObjectName: string]: InputObjectConfig; +} + // @public (undocumented) interface InvalidateModifier { // (undocumented) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 3d354499f5f..67a2505fae4 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'; @@ -660,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) diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index 57cf096758d..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; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 1baa2a075d7..1875b7f2ebc 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -127,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) @@ -1653,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) @@ -1667,6 +1671,7 @@ export type InMemoryCacheConfig = ApolloReducerConfig & { possibleTypes?: PossibleTypesMap; typePolicies?: TypePolicies; fragments?: FragmentRegistryAPI; + inputObjects?: InputObjectsOption; } & ({} extends InMemoryCache.ScalarsOption ? InMemoryCache.ScalarsOption extends Record ? { scalars?: Record; } : { @@ -1675,6 +1680,18 @@ export type InMemoryCacheConfig = ApolloReducerConfig & { scalars: InMemoryCache.ScalarsOption; }); +// @public (undocumented) +export interface InputObjectConfig { + // (undocumented) + fields: Record; +} + +// @public (undocumented) +export interface InputObjectsOption { + // (undocumented) + [inputObjectName: string]: InputObjectConfig; +} + // Warning: (ae-forgotten-export) The symbol "RefetchQueriesIncludeShorthand" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -3157,7 +3174,7 @@ interface WriteContext extends ReadMergeModifyContext { // 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:146:3 - (ae-forgotten-export) The symbol "FragmentRegistryAPI" 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/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/.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/package.json b/package.json index 074741ea6d0..19d900846bb 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 a4031b259e8..2b32dcaeafb 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -370,16 +370,20 @@ Array [ "createClientWrapper", "createMockWrapper", "createOperationWithDefaultContext", + "dateScalar", + "dateTimeScalar", "enableFakeTimers", "executeSchemaGraphQL17Alpha2", "executeSchemaGraphQL17Alpha9", "executeWithDefaultContext", "friendListSchemaGraphQL17Alpha2", "friendListSchemaGraphQL17Alpha9", + "jsonObjectScalar", "markAsStreaming", "mockDefer20220824", "mockDeferStreamGraphQL17Alpha9", "mockMultipartSubscriptionStream", + "priceScalar", "promiseWithResolvers", "renderAsync", "renderHookAsync", @@ -465,6 +469,7 @@ Array [ "getOperationName", "getQueryDefinition", "getStoreKeyName", + "getUnwrappedType", "graphQLResultHasError", "hasDirectives", "hasForcedResolvers", diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index de1ee0f88c5..c7e4cea75e6 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -260,6 +260,35 @@ export abstract class ApolloCache { 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 bbe9141ff68..4c188f93d45 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -52,6 +52,8 @@ export type { IdGetter, IdGetterObj, InMemoryCacheConfig, + InputObjectConfig, + InputObjectsOption, MergeInfo, MergeTree, NormalizedCache, 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..e1d33438076 --- /dev/null +++ b/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts @@ -0,0 +1,152 @@ +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", + }, + }); +}); 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 index 761ce813091..113f5c71673 100644 --- a/src/cache/inmemory/__tests__/scalars.ts +++ b/src/cache/inmemory/__tests__/scalars.ts @@ -4,32 +4,15 @@ 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"); -const dateTimeScalar = new Scalar({ - serialize: (value) => value.toISOString(), - parse: (value) => new Date(value), -}); - -const priceScalar = new Scalar({ - serialize: (dollars) => Math.round(parseFloat(dollars) * 100), - parse: (cents) => `${(cents / 100).toFixed(2)}`, - is: (value) => typeof value === "string", -}); - -const jsonObjectScalar = new Scalar< - Record, - Map ->({ - serialize: (value) => Object.fromEntries(value), - parse: (value) => new Map(Object.entries(value)), - is: (value) => value instanceof Map, -}); - test("creates a scalar from a GraphQLScalarType", () => { const graphQLScalar = new GraphQLScalarType({ name: "DateTime", 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 b06f9efd8c0..d2bfcc1b578 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -431,7 +431,7 @@ export abstract class EntityStore implements NormalizedCache { coerce: (scalar: Scalar, value: unknown) => unknown, typename = obj.__typename ): StoreObject { - if (!typename) { + if (!typename || !this.policies.cache["config"].scalars) { return obj; } diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc8b63fc806..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,8 +22,16 @@ import { print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import type { IsLooselyEqual } from "@apollo/client/utilities/internal"; -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"; @@ -210,6 +218,136 @@ export class InMemoryCache extends ApolloCache { 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 @@ -237,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 @@ -249,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, @@ -259,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(); @@ -304,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/types.ts b/src/cache/inmemory/types.ts index 3309e99f1f4..b010351130d 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -139,11 +139,20 @@ export type ApolloReducerConfig = { dataIdFromObject?: KeyFieldsFunction; }; +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 ? { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 70f166be3da..9389e2ffb76 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -314,7 +314,7 @@ export class QueryManager { this.mutationStore && (this.mutationStore[queryInfo.id] = { mutation, - variables, + variables: this.client.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..fae1eb22222 --- /dev/null +++ b/src/core/__tests__/client.mutate/customScalars.test.ts @@ -0,0 +1,160 @@ +import { 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: { 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", + }, + }); +}); 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..df8bee64663 --- /dev/null +++ b/src/core/__tests__/client.query/customScalars.test.ts @@ -0,0 +1,207 @@ +import { 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", + }, + }); +}); 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..467981e724f --- /dev/null +++ b/src/core/__tests__/client.readQuery/customScalars.test.ts @@ -0,0 +1,120 @@ +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" }, + }); +}); 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..95afa8732dd --- /dev/null +++ b/src/core/__tests__/client.refetchQueries/customScalars.test.ts @@ -0,0 +1,91 @@ +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 { 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" }); +}); 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..05b53afc889 --- /dev/null +++ b/src/core/__tests__/client.subscribe/customScalars.test.ts @@ -0,0 +1,172 @@ +import { 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", + }, + }); +}); 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..85e6b9682f1 --- /dev/null +++ b/src/core/__tests__/client.watchQuery/customScalars.test.ts @@ -0,0 +1,498 @@ +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 { 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" }); +}); 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 247a63efee8..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, 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__/useLazyQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx new file mode 100644 index 00000000000..c7ac1facda2 --- /dev/null +++ b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx @@ -0,0 +1,349 @@ +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", + }, + }); +}); 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..eed2a54b074 --- /dev/null +++ b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx @@ -0,0 +1,293 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} from "@testing-library/react-render-stream"; +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 { useMutation } 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: { + 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" }, + }); +}); 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..9d7b1e221dc --- /dev/null +++ b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx @@ -0,0 +1,241 @@ +import { + disableActEnvironment, + renderHookToSnapshotStream, +} 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 { 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", + }, + }); +}); 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..fd03782ed98 --- /dev/null +++ b/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx @@ -0,0 +1,205 @@ +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" }, + }); +}); 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..1940718707d --- /dev/null +++ b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx @@ -0,0 +1,218 @@ +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 { 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", + }, + }); +}); 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 204d6b55a84..074f821632b 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -45,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"; From 263f987bf8a604412baf4d54140f6978bd8d9afe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 29 Jun 2026 20:20:40 -0400 Subject: [PATCH 6/6] Fix scalar parsing for subscriptions and mutations + add tests for all fetch policies (#13275) Adds tests for all APIs to test parsing scalars for all fetch policies. This revealed an issue with mutations and subscriptions where the parsed scalar value wasn't used correctly. This has been fixed in this PR. --- .../cache.diff/customScalars.test.ts | 212 +++++ .../cache.readFragment/customScalars.test.ts | 42 + .../cache.readQuery/customScalars.test.ts | 41 + src/core/QueryInfo.ts | 80 +- src/core/QueryManager.ts | 2 +- .../client.mutate/customScalars.test.ts | 508 ++++++++++- .../client.query/customScalars.test.ts | 240 ++++- .../client.readFragment/customScalars.test.ts | 45 + .../client.readQuery/customScalars.test.ts | 44 + .../customScalars.test.ts | 109 +++ .../client.subscribe/customScalars.test.ts | 233 ++++- .../customScalars.test.ts | 71 ++ .../client.watchQuery/customScalars.test.ts | 426 ++++++++- .../useFragment/customScalars.test.tsx | 83 ++ .../useLazyQuery/customScalars.test.tsx | 709 +++++++++++++++ .../useMutation/customScalars.test.tsx | 822 +++++++++++++++++- .../__tests__/useQuery/customScalars.test.tsx | 459 +++++++++- .../useSubscription/customScalars.test.tsx | 267 ++++++ .../customScalars.test.tsx | 79 ++ .../useSuspenseQuery/customScalars.test.tsx | 415 ++++++++- 20 files changed, 4840 insertions(+), 47 deletions(-) create mode 100644 src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts create mode 100644 src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts create mode 100644 src/core/__tests__/client.readFragment/customScalars.test.ts create mode 100644 src/core/__tests__/client.watchFragment/customScalars.test.ts create mode 100644 src/react/hooks/__tests__/useFragment/customScalars.test.tsx create mode 100644 src/react/hooks/__tests__/useSuspenseFragment/customScalars.test.tsx 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 index e1d33438076..fbd346b6c26 100644 --- a/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts +++ b/src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts @@ -150,3 +150,44 @@ test("serializes scalar fields in input object variables", () => { }, }); }); + +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/core/QueryInfo.ts b/src/core/QueryInfo.ts index 02c04e5553a..c3a905a9633 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 9389e2ffb76..c825d24f4c1 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -314,7 +314,7 @@ export class QueryManager { this.mutationStore && (this.mutationStore[queryInfo.id] = { mutation, - variables: this.client.cache.serializeVariables(mutation, variables), + variables: this.cache.serializeVariables(mutation, variables), loading: true, error: null, } as MutationStoreValue); diff --git a/src/core/__tests__/client.mutate/customScalars.test.ts b/src/core/__tests__/client.mutate/customScalars.test.ts index fae1eb22222..c2444645784 100644 --- a/src/core/__tests__/client.mutate/customScalars.test.ts +++ b/src/core/__tests__/client.mutate/customScalars.test.ts @@ -1,9 +1,10 @@ -import { of } from "rxjs"; +import { delay, of } from "rxjs"; -import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { dateScalar } from "@apollo/client/testing/internal"; +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; @@ -158,3 +159,502 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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 index df8bee64663..b4071d63b4b 100644 --- a/src/core/__tests__/client.query/customScalars.test.ts +++ b/src/core/__tests__/client.query/customScalars.test.ts @@ -1,4 +1,4 @@ -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; import { ApolloClient, ApolloLink, gql } from "@apollo/client"; @@ -205,3 +205,241 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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 index 467981e724f..16890c39044 100644 --- a/src/core/__tests__/client.readQuery/customScalars.test.ts +++ b/src/core/__tests__/client.readQuery/customScalars.test.ts @@ -118,3 +118,47 @@ test("serializes scalar fields in input object variables", () => { 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 index 95afa8732dd..859eb613699 100644 --- a/src/core/__tests__/client.refetchQueries/customScalars.test.ts +++ b/src/core/__tests__/client.refetchQueries/customScalars.test.ts @@ -3,6 +3,7 @@ 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 () => { @@ -89,3 +90,111 @@ test("serializes scalar variables when refetching an active query", async () => 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 index 05b53afc889..0e77a52a7a6 100644 --- a/src/core/__tests__/client.subscribe/customScalars.test.ts +++ b/src/core/__tests__/client.subscribe/customScalars.test.ts @@ -1,4 +1,4 @@ -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; import { ApolloClient, ApolloLink, gql } from "@apollo/client"; @@ -170,3 +170,234 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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 index 85e6b9682f1..c5a3c3cfafa 100644 --- a/src/core/__tests__/client.watchQuery/customScalars.test.ts +++ b/src/core/__tests__/client.watchQuery/customScalars.test.ts @@ -1,4 +1,4 @@ -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; @@ -496,3 +496,427 @@ test("serializes scalar variables passed to subscribeToMore", async () => { 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/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 index c7ac1facda2..10118829de4 100644 --- a/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx @@ -347,3 +347,712 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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__/useMutation/customScalars.test.tsx b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx index eed2a54b074..a7f5fd20736 100644 --- a/src/react/hooks/__tests__/useMutation/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx @@ -4,10 +4,11 @@ import { } from "@testing-library/react-render-stream"; import { delay, of } from "rxjs"; -import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; +import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { useMutation } from "@apollo/client/react"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { MockLink } from "@apollo/client/testing"; import { createClientWrapper, dateScalar, @@ -291,3 +292,818 @@ test("serializes scalar fields in input object variables", async () => { 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 index 9d7b1e221dc..a92bbd12abc 100644 --- a/src/react/hooks/__tests__/useQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx @@ -2,7 +2,7 @@ import { disableActEnvironment, renderHookToSnapshotStream, } from "@testing-library/react-render-stream"; -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; @@ -239,3 +239,460 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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__/useSubscription/customScalars.test.tsx b/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx index fd03782ed98..5f9741aef45 100644 --- a/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useSubscription/customScalars.test.tsx @@ -203,3 +203,270 @@ test("serializes scalar fields in input object variables", async () => { 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 index 1940718707d..504ed0dc071 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx @@ -1,5 +1,5 @@ import { disableActEnvironment } from "@testing-library/react-render-stream"; -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; @@ -216,3 +216,416 @@ test("serializes scalar fields in input object variables", async () => { }, }); }); + +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, + }); + } + } +);