diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index f0b1aa24fb7..2f45e963fe2 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -30,6 +30,7 @@ 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 { OperationTypeNode } from 'graphql'; import type { OperationVariables } from '@apollo/client'; import type { Prettify } from '@apollo/client/utilities/internal'; import { Reference } from '@apollo/client/utilities'; @@ -110,7 +111,10 @@ export abstract class ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) + getRootTypename(operation: OperationTypeNode): string | undefined; + // (undocumented) getScalar(key: TKey): ApolloCache.GetScalarType | undefined; + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) @@ -664,7 +668,10 @@ export class InMemoryCache extends ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getInMemoryCacheMemoryInternals; // (undocumented) + getRootTypename(operation: OperationTypeNode): string; + // (undocumented) getScalar(key: TKey): ApolloCache.GetScalarType extends (Scalar) ? IsLooselyEqual extends true ? ApolloCache.GetScalarType | undefined : ApolloCache.GetScalarType : never; + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) @@ -1154,8 +1161,8 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/core/cache.ts:204:7 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal -// src/cache/inmemory/inMemoryCache.ts:456:7 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal +// src/cache/core/cache.ts:205:7 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal +// src/cache/inmemory/inMemoryCache.ts:469:7 - (ae-incompatible-release-tags) The symbol "[handleIncrementalSymbol]" is marked as @public, but its signature references "DiffIncrementalInfo" which is marked as @internal // 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:179:3 - (ae-forgotten-export) The symbol "ScalarNames" needs to be exported by the entry point index.d.ts // src/cache/inmemory/types.ts:147:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 87c20db02fc..25ded68d080 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -1376,7 +1376,7 @@ export const windowFocusSource: RefetchEventManager.EventSource; // // 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 -// src/core/QueryManager.ts:195:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:196:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.api-reports/api-report-utilities_internal.api.md b/.api-reports/api-report-utilities_internal.api.md index fe0d5edd1e8..b4ed75a07da 100644 --- a/.api-reports/api-report-utilities_internal.api.md +++ b/.api-reports/api-report-utilities_internal.api.md @@ -4,7 +4,8 @@ ```ts -import type { ApolloCache } from '@apollo/client'; +import type { ApolloCache } from '@apollo/client/cache'; +import type { ApolloCache as ApolloCache_2 } from '@apollo/client'; import type { ApolloClient } from '@apollo/client'; import type { ASTNode } from 'graphql'; import type { DataValue } from '@apollo/client'; @@ -86,6 +87,9 @@ export type ClassicSignature = SignatureStyle extends "classic" ? unknown : neve // @internal @deprecated export function cloneDeep(value: T): T; +// @public (undocumented) +export function coerceScalarFieldsToParsed(result: Record, query: DocumentNode, cache: ApolloCache): Record; + // @public export function combineLatestBatched(observables: Array & { dirty?: boolean; @@ -425,7 +429,7 @@ export function makeStreamInfoTrie(): StreamInfoTrie; export function makeUniqueId(prefix: string): string; // @public (undocumented) -export const mapObservableFragmentMemoized: (observable: ApolloCache.ObservableFragment, _cacheKey: symbol, mapFn: (from: ApolloCache.WatchFragmentResult) => ApolloCache.WatchFragmentResult) => ApolloCache.ObservableFragment; +export const mapObservableFragmentMemoized: (observable: ApolloCache_2.ObservableFragment, _cacheKey: symbol, mapFn: (from: ApolloCache_2.WatchFragmentResult) => ApolloCache_2.WatchFragmentResult) => ApolloCache_2.ObservableFragment; // @internal @deprecated (undocumented) export function maybeDeepFreeze(obj: T): T; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 53cea14d0a0..590323f6ba6 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -104,7 +104,10 @@ export abstract class ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getApolloCacheMemoryInternals; // (undocumented) + getRootTypename(operation: OperationTypeNode): string | undefined; + // (undocumented) getScalar(key: TKey): ApolloCache.GetScalarType | undefined; + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) @@ -1696,7 +1699,10 @@ export class InMemoryCache extends ApolloCache { // @internal @deprecated getMemoryInternals?: typeof getInMemoryCacheMemoryInternals; // (undocumented) + getRootTypename(operation: OperationTypeNode): string; + // (undocumented) getScalar(key: TKey): ApolloCache.GetScalarType extends (Scalar) ? IsLooselyEqual extends true ? ApolloCache.GetScalarType | undefined : ApolloCache.GetScalarType : never; + getScalarForField(typename: string, fieldName: string): Scalar | undefined; // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) @@ -3251,8 +3257,8 @@ interface WriteContext extends ReadMergeModifyContext { // Warnings were encountered during analysis: // -// src/cache/core/cache.ts:129:11 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts -// src/cache/core/cache.ts:204:7 - (ae-forgotten-export) The symbol "DiffIncrementalInfo" needs to be exported by the entry point index.d.ts +// src/cache/core/cache.ts:130:11 - (ae-forgotten-export) The symbol "MissingTree" needs to be exported by the entry point index.d.ts +// src/cache/core/cache.ts:205:7 - (ae-forgotten-export) The symbol "DiffIncrementalInfo" 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 @@ -3262,7 +3268,7 @@ interface WriteContext extends ReadMergeModifyContext { // 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 -// src/core/QueryManager.ts:195:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:196:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts // src/local-state/LocalState.ts:149:5 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // src/local-state/LocalState.ts:202:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts // src/local-state/LocalState.ts:245:7 - (ae-forgotten-export) The symbol "LocalState" needs to be exported by the entry point index.d.ts diff --git a/.changeset/nasty-keys-brush.md b/.changeset/nasty-keys-brush.md new file mode 100644 index 00000000000..dbaabf09616 --- /dev/null +++ b/.changeset/nasty-keys-brush.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Parse scalar fields for `no-cache` queries. diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index dd3709e9d5b..6312fbb66ec 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -446,6 +446,7 @@ Array [ "canonicalStringify", "checkDocument", "cloneDeep", + "coerceScalarFieldsToParsed", "combineLatestBatched", "compact", "createFragmentMap", diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index df361c0ff81..7701ecde429 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -5,6 +5,7 @@ import type { DocumentNode, FragmentDefinitionNode, InlineFragmentNode, + OperationTypeNode, } from "graphql"; import { wrap } from "optimism"; import { @@ -263,6 +264,10 @@ export abstract class ApolloCache { return null; } + public getRootTypename(operation: OperationTypeNode): string | undefined { + return; + } + // Custom scalars API public getScalar( @@ -271,6 +276,14 @@ export abstract class ApolloCache { return; } + /** Get a scalar instance for a field in a type */ + public getScalarForField( + typename: string, + fieldName: string + ): Scalar | undefined { + return; + } + /** * Serializes scalar values in the variables object */ diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index b3529b24d7c..8fce8aeac21 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -3,6 +3,7 @@ import type { DocumentNode, FragmentDefinitionNode, InlineFragmentNode, + OperationTypeNode, } from "graphql"; import type { OptimisticWrapperFunction } from "optimism"; import { wrap } from "optimism"; @@ -206,6 +207,10 @@ export class InMemoryCache extends ApolloCache { ); } + public getRootTypename(operation: OperationTypeNode): string { + return this.policies.rootTypenamesById[`ROOT_${operation.toUpperCase()}`]; + } + public getScalar( key: TKey ): ApolloCache.GetScalarType extends ( @@ -220,6 +225,14 @@ export class InMemoryCache extends ApolloCache { return this.config.scalars?.[key as string] as any; } + /** Get a scalar instance for a field in a type */ + public getScalarForField( + typename: string, + fieldName: string + ): Scalar | undefined { + return this.policies.getScalarForField(typename, fieldName); + } + /** * {@inheritDoc @apollo/client/cache!ApolloCache#serializeVariables:member(1)} */ diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 6ad1dd2699c..04ec94c2ebb 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -51,6 +51,7 @@ import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal import { AutoCleanedWeakCache, checkDocument, + coerceScalarFieldsToParsed, extensionsSymbol, filterMap, getDefaultValues, @@ -1756,7 +1757,25 @@ export class QueryManager { return { fromLink: true, observable: resultsFromLink() }; case "no-cache": - return { fromLink: true, observable: resultsFromLink() }; + return { + fromLink: true, + observable: resultsFromLink().pipe( + map((notification) => { + if ( + notification.kind === "N" && + notification.value.data != null + ) { + notification.value.data = coerceScalarFieldsToParsed( + notification.value.data, + query, + this.cache + ) as TData; + } + + return notification; + }) + ), + }; case "standby": return { fromLink: false, observable: EMPTY }; diff --git a/src/core/__tests__/client.query/customScalars.test.ts b/src/core/__tests__/client.query/customScalars.test.ts index 07141c18341..e266b27f7d9 100644 --- a/src/core/__tests__/client.query/customScalars.test.ts +++ b/src/core/__tests__/client.query/customScalars.test.ts @@ -397,57 +397,54 @@ test("parses network custom scalar fields with a network-only fetch policy", asy }); }); -test.failing( - "parses custom scalar fields with a no-cache fetch policy", - async () => { - const query = gql` - query Event { - event { - id - startDate - } +test("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" }, - }, + } + `; + 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", - }, + }, + }), + 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), }, + }).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), }, - }); - } -); + }, + }); +}); test("preserves referential identity when fetching identical serialized scalar values", async () => { const query = gql` diff --git a/src/core/__tests__/client.watchQuery/customScalars.test.ts b/src/core/__tests__/client.watchQuery/customScalars.test.ts index 73eb0831416..e02e184bfb8 100644 --- a/src/core/__tests__/client.watchQuery/customScalars.test.ts +++ b/src/core/__tests__/client.watchQuery/customScalars.test.ts @@ -872,70 +872,67 @@ test("parses network custom scalar fields with a network-only fetch policy", asy 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 - } +test("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" }, - }, + } + `; + 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", - }, + }, + }), + 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), }, + }).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, - }); - } -); + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); +}); test("preserves referential identity when refetching identical serialized scalar values", async () => { const query = gql` diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts new file mode 100644 index 00000000000..b9c2228ca2b --- /dev/null +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -0,0 +1,1309 @@ +import { gql } from "@apollo/client"; +import { InMemoryCache } from "@apollo/client/cache"; +import { + dateScalar, + jsonObjectScalar, + priceScalar, +} from "@apollo/client/testing/internal"; +import { coerceScalarFieldsToParsed } from "@apollo/client/utilities/internal"; + +test("parses custom scalar fields on nested objects", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + name + startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields when the query selects __typename", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + __typename + id + startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("does not parse fields on objects without a __typename", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Query: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + Conference: { + fields: { + openedAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query EventAndConference { + event { + id + startDate + } + conference { + id + venue { + openedAt + } + } + } + `; + + const result = { + event: { + id: "1", + startDate: "2026-01-01", + }, + conference: { + __typename: "Conference", + id: "2", + venue: { + openedAt: "2026-02-02", + }, + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + id: "1", + startDate: "2026-01-01", + }, + conference: { + __typename: "Conference", + id: "2", + venue: { + openedAt: "2026-02-02", + }, + }, + }); +}); + +test("parses custom scalar fields on root fields", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Query: { + fields: { + today: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Today { + today + } + `; + + const result = { today: "2026-01-01" }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + today: new Date(2026, 0, 1), + }); +}); + +test("parses custom scalar fields for aliased fields", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + start: startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + start: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + start: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields in deeply nested objects", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Session: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + keynote { + id + session { + id + startDate + } + } + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + keynote: { + __typename: "Talk", + id: "2", + session: { + __typename: "Session", + id: "3", + startDate: "2026-01-01", + }, + }, + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + keynote: { + __typename: "Talk", + id: "2", + session: { + __typename: "Session", + id: "3", + startDate: new Date(2026, 0, 1), + }, + }, + }, + }); +}); + +test("parses custom scalar fields for objects in a list", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Events { + events { + id + startDate + } + } + `; + + const result = { + events: [ + { __typename: "Event", id: "1", startDate: "2026-01-01" }, + { __typename: "Event", id: "2", startDate: "2026-06-15" }, + ], + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + events: [ + { __typename: "Event", id: "1", startDate: new Date(2026, 0, 1) }, + { __typename: "Event", id: "2", startDate: new Date(2026, 5, 15) }, + ], + }); +}); + +test("parses custom scalar values in a list of scalars", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + dates: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + dates + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + dates: ["2026-01-01", "2026-06-15"], + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + dates: [new Date(2026, 0, 1), new Date(2026, 5, 15)], + }, + }); +}); + +test("parses custom scalar fields for objects in a list of lists", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query EventsByYear { + eventsByYear { + id + startDate + } + } + `; + + const result = { + eventsByYear: [ + [ + { __typename: "Event", id: "1", startDate: "2026-01-01" }, + { __typename: "Event", id: "2", startDate: "2026-06-15" }, + ], + [{ __typename: "Event", id: "3", startDate: "2027-03-10" }], + ], + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + eventsByYear: [ + [ + { __typename: "Event", id: "1", startDate: new Date(2026, 0, 1) }, + { __typename: "Event", id: "2", startDate: new Date(2026, 5, 15) }, + ], + [{ __typename: "Event", id: "3", startDate: new Date(2027, 2, 10) }], + ], + }); +}); + +test("parses custom scalar values in a list of lists", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + datesByYear: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + datesByYear + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + datesByYear: [["2026-01-01", "2026-06-15"], ["2027-03-10"]], + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + datesByYear: [ + [new Date(2026, 0, 1), new Date(2026, 5, 15)], + [new Date(2027, 2, 10)], + ], + }, + }); +}); + +test("parses custom scalar fields selected by a named fragment", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + ...EventFields + } + } + + fragment EventFields on Event { + name + startDate + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields selected by nested fragments", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + ...EventFields + } + } + + fragment EventFields on Event { + name + ...EventDateFields + } + + fragment EventDateFields on Event { + startDate + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + name: "GraphQL Summit", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields selected by an inline fragment", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + ... on Event { + startDate + } + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields selected by an inline fragment without a type condition", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + ... { + startDate + } + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields selected by a fragment spread at the root of the query", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + User: { + fields: { + lastSeenAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query EventAndViewer { + event { + id + startDate + } + ...ViewerFields + } + + fragment ViewerFields on Query { + viewer { + id + lastSeenAt + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + viewer: { + __typename: "User", + id: "2", + lastSeenAt: "2026-02-02", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + viewer: { + __typename: "User", + id: "2", + lastSeenAt: new Date(2026, 1, 2), + }, + }); +}); + +test("keeps parsed values when a fragment selects no custom scalar fields", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + startDate + ...EventName + } + } + + fragment EventName on Event { + name + } + `; + + const result = { + event: { + __typename: "Event", + name: "GraphQL Summit", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + name: "GraphQL Summit", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("parses custom scalar fields selected by a fragment on an interface type", () => { + const cache = new InMemoryCache({ + possibleTypes: { Node: ["Event"] }, + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + createdAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + ... on Node { + createdAt + } + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + createdAt: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + createdAt: new Date(2026, 0, 1), + }, + }); +}); + +test("does not drop fields selected by a fragment on a supertype missing from possibleTypes", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + createdAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + startDate + ... on Node { + createdAt + } + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-06-15", + createdAt: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 5, 15), + createdAt: new Date(2026, 0, 1), + }, + }); +}); + +test("ignores fields from inline fragments that don't match the returned type", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Dog: { + fields: { + adoptedAt: { scalar: "Date" }, + }, + }, + Cat: { + fields: { + microchippedAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Pet { + pet { + ... on Dog { + name + adoptedAt + } + ... on Cat { + name + microchippedAt + } + } + } + `; + + const result = { + pet: { + __typename: "Dog", + name: "Fido", + adoptedAt: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + pet: { + __typename: "Dog", + name: "Fido", + adoptedAt: new Date(2026, 0, 1), + }, + }); +}); + +test("does not parse values that are already parsed", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); +}); + +test("leaves null scalar values as-is", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: null, + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: null, + }, + }); +}); + +test("leaves null values in a list as-is", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + dates: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Events { + events { + id + startDate + dates + } + } + `; + + const result = { + events: [ + null, + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + dates: [null, "2026-06-15"], + }, + ], + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + events: [ + null, + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + dates: [null, new Date(2026, 5, 15)], + }, + ], + }); +}); + +test("leaves null objects as-is", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + startDate + } + } + `; + + const result = { event: null }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: null, + }); +}); + +test("does not modify fields without a configured scalar", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + }); + + const query = gql` + query Event { + event { + id + startDate + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toBe(result); +}); + +test("maintains referential equality of unchanged subtrees", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query EventAndViewer { + event { + id + startDate + } + viewer { + id + name + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + viewer: { + __typename: "User", + id: "2", + name: "Test User", + }, + }; + + const coerced = coerceScalarFieldsToParsed(result, query, cache); + + expect(coerced).not.toBe(result); + expect(coerced.event).not.toBe(result.event); + expect(coerced.viewer).toBe(result.viewer); +}); + +test("maintains referential equality of unchanged objects in a list", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Events { + events { + id + name + } + } + `; + + const result = { + events: [ + { __typename: "Event", id: "1", name: "GraphQL Summit" }, + { __typename: "Event", id: "2", name: "GraphQL Conf" }, + ], + }; + + const coerced = coerceScalarFieldsToParsed(result, query, cache); + + expect(coerced).toBe(result); + expect(coerced.events).toBe(result.events); +}); + +test("parses scalars that serialize to primitive values", () => { + const cache = new InMemoryCache({ + scalars: { Price: priceScalar }, + typePolicies: { + Product: { + fields: { + price: { scalar: "Price" }, + }, + }, + }, + }); + + const query = gql` + query Product { + product { + id + price + } + } + `; + + const result = { + product: { + __typename: "Product", + id: "1", + price: 1099, + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + product: { + __typename: "Product", + id: "1", + price: "10.99", + }, + }); +}); + +test("parses scalars that serialize to object values", () => { + const cache = new InMemoryCache({ + scalars: { JSONObject: jsonObjectScalar }, + typePolicies: { + Event: { + fields: { + metadata: { scalar: "JSONObject" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + metadata + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + metadata: { attendees: 500 }, + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + metadata: new Map([["attendees", 500]]), + }, + }); +}); + +test("parses custom scalar fields on mutation root fields", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Mutation: { + fields: { + touchedAt: { scalar: "Date" }, + }, + }, + }, + }); + + const mutation = gql` + mutation TouchEvent { + touchedAt + } + `; + + const result = { touchedAt: "2026-01-01" }; + + expect( + coerceScalarFieldsToParsed(result, mutation, cache) + ).toStrictEqualTyped({ + touchedAt: new Date(2026, 0, 1), + }); +}); + +test("parses custom scalar fields on renamed root types", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + RootQuery: { + queryType: true, + fields: { + today: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Today { + today + } + `; + + const result = { today: "2026-01-01" }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + today: new Date(2026, 0, 1), + }); +}); + +test("parses custom scalar fields configured on an interface type", () => { + const cache = new InMemoryCache({ + possibleTypes: { Node: ["Event"] }, + scalars: { Date: dateScalar }, + typePolicies: { + Node: { + fields: { + createdAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + createdAt + } + } + `; + + const result = { + event: { + __typename: "Event", + id: "1", + createdAt: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + createdAt: new Date(2026, 0, 1), + }, + }); +}); + +test("does not reparse values when run against an already parsed result", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar, Price: priceScalar }, + typePolicies: { + Product: { + fields: { + price: { scalar: "Price" }, + releasedAt: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Product { + product { + id + price + releasedAt + } + } + `; + + const result = { + product: { + __typename: "Product", + id: "1", + price: 1099, + releasedAt: "2026-01-01", + }, + }; + + const parsed = coerceScalarFieldsToParsed(result, query, cache); + + expect(parsed).toStrictEqualTyped({ + product: { + __typename: "Product", + id: "1", + price: "10.99", + releasedAt: new Date(2026, 0, 1), + }, + }); + expect(coerceScalarFieldsToParsed(parsed, query, cache)).toBe(parsed); +}); diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts new file mode 100644 index 00000000000..c860993e10b --- /dev/null +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -0,0 +1,96 @@ +import type { DocumentNode, FieldNode, SelectionSetNode } from "graphql"; + +import type { ApolloCache } from "@apollo/client/cache"; +import { invariant } from "@apollo/client/utilities/invariant"; + +import { createFragmentMap } from "./createFragmentMap.js"; +import { getFragmentDefinitions } from "./getFragmentDefinitions.js"; +import { getFragmentFromSelection } from "./getFragmentFromSelection.js"; +import { getMainDefinition } from "./getMainDefinition.js"; +import { getOperationDefinition } from "./getOperationDefinition.js"; +import { isField } from "./isField.js"; +import { resultKeyNameFromField } from "./resultKeyNameFromField.js"; + +export function coerceScalarFieldsToParsed( + result: Record, + query: DocumentNode, + cache: ApolloCache +): Record { + const operationType = getOperationDefinition(query)?.operation; + const fragmentMap = createFragmentMap(getFragmentDefinitions(query)); + + invariant( + operationType, + "Document node must be a query, mutation, or subscription" + ); + + function coerceField( + field: FieldNode, + fieldValue: unknown, + typename: string | undefined + ): unknown { + let changed = false; + + if (Array.isArray(fieldValue)) { + const items = fieldValue.map((item) => { + const coerced = coerceField(field, item, typename); + + changed ||= coerced !== item; + return coerced; + }); + + return changed ? items : fieldValue; + } + + if (field.selectionSet) { + return coerceSelectionSet(field.selectionSet, fieldValue); + } + + if (fieldValue === null || !typename) return fieldValue; + + const scalar = cache.getScalarForField(typename, field.name.value); + return scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; + } + + function coerceSelectionSet( + selectionSet: SelectionSetNode, + data: any, + typename?: string + ): any { + if (data === null || typeof data !== "object") return data; + + const result: Record = { ...data }; + let changed = false; + + if (Object.hasOwn(data, "__typename")) { + typename = data.__typename; + } + + const workSet = new Set(selectionSet.selections); + workSet.forEach((selection) => { + if (isField(selection)) { + const resultName = resultKeyNameFromField(selection); + if (!Object.hasOwn(data, resultName)) return; + + const fieldValue = data[resultName]; + const coerced = coerceField(selection, fieldValue, typename); + + changed ||= coerced !== fieldValue; + result[resultName] = coerced; + } else { + getFragmentFromSelection( + selection, + fragmentMap + )?.selectionSet.selections.forEach((s) => workSet.add(s)); + } + }); + + return changed ? result : data; + } + + return coerceSelectionSet( + getMainDefinition(query).selectionSet, + result, + cache.getRootTypename(operationType) + ); +} diff --git a/src/utilities/internal/index.ts b/src/utilities/internal/index.ts index 80d55d4f95e..eea3063069f 100644 --- a/src/utilities/internal/index.ts +++ b/src/utilities/internal/index.ts @@ -27,6 +27,7 @@ export { argumentsObjectFromField } from "./argumentsObjectFromField.js"; export { canUseDOM } from "./canUseDOM.js"; export { checkDocument } from "./checkDocument.js"; export { cloneDeep } from "./cloneDeep.js"; +export { coerceScalarFieldsToParsed } from "./coerceScalarFieldsToParsed.js"; export { combineLatestBatched } from "./combineLatestBatched.js"; export { compact } from "./compact.js"; export { createFragmentMap } from "./createFragmentMap.js";