From 78427c050605383e818eb13ce826de68f4cc2832 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 00:09:28 -0600 Subject: [PATCH 01/31] Add getScalarForField method --- src/cache/core/cache.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index df361c0ff81..bd44a6b3736 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -271,6 +271,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 */ From f16bdf004f31517a640d536bdae72d71c3883fa2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 00:11:55 -0600 Subject: [PATCH 02/31] Implement getScalarForField in InMemoryCache --- src/cache/inmemory/inMemoryCache.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index b3529b24d7c..08764ba051b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -220,6 +220,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)} */ From 40775c5f97b760bd8168f66de91855d6c3306cd3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 00:46:03 -0600 Subject: [PATCH 03/31] Process scalar fields for no-cache queries --- src/core/QueryManager.ts | 113 +++++++++++++++++- .../client.watchQuery/customScalars.test.ts | 113 +++++++++--------- 2 files changed, 166 insertions(+), 60 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 6ad1dd2699c..34fd1944816 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -4,6 +4,7 @@ import type { DocumentNode, FormattedExecutionResult, } from "graphql"; +import type { SelectionSetNode } from "graphql"; import { BREAK, Kind, OperationTypeNode, visit } from "graphql"; import { Observable, throwError } from "rxjs"; import { @@ -43,27 +44,36 @@ import type { DeepPartial } from "@apollo/client/utilities"; import { cacheSizes, DocumentTransform, + getMainDefinition, isNetworkRequestInFlight, print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal"; +import type { + ExtensionsWithStreamInfo, + FragmentMap, +} from "@apollo/client/utilities/internal"; import { AutoCleanedWeakCache, checkDocument, + createFragmentMap, extensionsSymbol, filterMap, getDefaultValues, + getFragmentDefinitions, + getFragmentFromSelection, getOperationDefinition, getOperationName, graphQLResultHasError, hasDirectives, hasForcedResolvers, isDocumentNode, + isField, isNonNullObject, makeUniqueId, mergeOptions, removeDirectivesFromDocument, + resultKeyNameFromField, streamInfoSymbol, toQueryResult, } from "@apollo/client/utilities/internal"; @@ -1756,12 +1766,111 @@ 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.source !== "network" || + notification.kind !== "N" + ) { + return notification; + } + + return { + ...notification, + value: { + ...notification.value, + data: this.processScalars(query, notification.value.data), + }, + }; + }) + ), + }; case "standby": return { fromLink: false, observable: EMPTY }; } } + + private processScalars(query: DocumentNode, data: unknown): any { + const process = ( + selectionSet: SelectionSetNode, + data: any, + fragmentMap: FragmentMap + ): any => { + if (data == null) return data; + + const result: Record = {}; + let changed = false; + + for (const selection of selectionSet.selections) { + if (isField(selection)) { + const resultName = resultKeyNameFromField(selection); + const fieldValue = data[resultName]; + + if (Array.isArray(fieldValue)) { + const processed = fieldValue.map((item) => { + const processedItem = process(selectionSet, item, fragmentMap); + changed ||= item !== processedItem; + + return processedItem; + }); + + result[resultName] = processed; + + continue; + } else if (selection.selectionSet) { + const processed = process( + selection.selectionSet, + fieldValue, + fragmentMap + ); + + changed ||= processed !== fieldValue; + result[resultName] = processed; + + continue; + } + + const typename = + Object.hasOwn(data, "__typename") ? data.__typename : undefined; + + if (!typename) { + return data; + } + + const scalar = this.cache.getScalarForField( + typename, + selection.name.value + ); + + if (scalar) { + result[resultName] = scalar.coerceToParsed(fieldValue); + changed = true; + } else { + result[resultName] = fieldValue; + } + } else { + const fragment = getFragmentFromSelection(selection, fragmentMap); + + const processed = + fragment ? process(fragment.selectionSet, data, fragmentMap) : data; + changed ||= processed !== data; + + Object.assign(result, processed); + } + } + + return changed ? result : data; + }; + + return process( + getMainDefinition(query).selectionSet, + data, + createFragmentMap(getFragmentDefinitions(query)) + ); + } } function validateDidEmitValue() { 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` From 1286e8647ac6591fddfcbb9d0ad89a39f0356f3a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 00:59:20 -0600 Subject: [PATCH 04/31] Extract coerceScalarFieldsToParsed utility --- src/core/QueryManager.ts | 99 ++----------------- .../internal/coerceScalarFieldsToParsed.ts | 86 ++++++++++++++++ src/utilities/internal/index.ts | 1 + 3 files changed, 97 insertions(+), 89 deletions(-) create mode 100644 src/utilities/internal/coerceScalarFieldsToParsed.ts diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 34fd1944816..6a227ea9db8 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -56,6 +56,7 @@ import type { import { AutoCleanedWeakCache, checkDocument, + coerceScalarFieldsToParsed, createFragmentMap, extensionsSymbol, filterMap, @@ -1771,19 +1772,18 @@ export class QueryManager { observable: resultsFromLink().pipe( map((notification) => { if ( - notification.source !== "network" || - notification.kind !== "N" + notification.source === "network" && + notification.kind === "N" && + notification.value.data != null ) { - return notification; + notification.value.data = coerceScalarFieldsToParsed( + notification.value.data, + query, + this.cache + ) as TData; } - return { - ...notification, - value: { - ...notification.value, - data: this.processScalars(query, notification.value.data), - }, - }; + return notification; }) ), }; @@ -1792,85 +1792,6 @@ export class QueryManager { return { fromLink: false, observable: EMPTY }; } } - - private processScalars(query: DocumentNode, data: unknown): any { - const process = ( - selectionSet: SelectionSetNode, - data: any, - fragmentMap: FragmentMap - ): any => { - if (data == null) return data; - - const result: Record = {}; - let changed = false; - - for (const selection of selectionSet.selections) { - if (isField(selection)) { - const resultName = resultKeyNameFromField(selection); - const fieldValue = data[resultName]; - - if (Array.isArray(fieldValue)) { - const processed = fieldValue.map((item) => { - const processedItem = process(selectionSet, item, fragmentMap); - changed ||= item !== processedItem; - - return processedItem; - }); - - result[resultName] = processed; - - continue; - } else if (selection.selectionSet) { - const processed = process( - selection.selectionSet, - fieldValue, - fragmentMap - ); - - changed ||= processed !== fieldValue; - result[resultName] = processed; - - continue; - } - - const typename = - Object.hasOwn(data, "__typename") ? data.__typename : undefined; - - if (!typename) { - return data; - } - - const scalar = this.cache.getScalarForField( - typename, - selection.name.value - ); - - if (scalar) { - result[resultName] = scalar.coerceToParsed(fieldValue); - changed = true; - } else { - result[resultName] = fieldValue; - } - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - - const processed = - fragment ? process(fragment.selectionSet, data, fragmentMap) : data; - changed ||= processed !== data; - - Object.assign(result, processed); - } - } - - return changed ? result : data; - }; - - return process( - getMainDefinition(query).selectionSet, - data, - createFragmentMap(getFragmentDefinitions(query)) - ); - } } function validateDidEmitValue() { diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts new file mode 100644 index 00000000000..46da4931033 --- /dev/null +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -0,0 +1,86 @@ +import type { DocumentNode, SelectionSetNode } from "graphql"; + +import type { ApolloCache } from "@apollo/client/cache"; + +import { createFragmentMap } from "./createFragmentMap.js"; +import { getFragmentDefinitions } from "./getFragmentDefinitions.js"; +import { getFragmentFromSelection } from "./getFragmentFromSelection.js"; +import { getMainDefinition } from "./getMainDefinition.js"; +import { isField } from "./isField.js"; +import { resultKeyNameFromField } from "./resultKeyNameFromField.js"; +import type { FragmentMap } from "./types/FragmentMap.js"; + +export function coerceScalarFieldsToParsed( + result: Record, + query: DocumentNode, + cache: ApolloCache +): Record { + const coerce = ( + selectionSet: SelectionSetNode, + data: any, + fragmentMap: FragmentMap + ): any => { + if (data == null) return data; + + const result: Record = {}; + let changed = false; + + for (const selection of selectionSet.selections) { + if (isField(selection)) { + const resultName = resultKeyNameFromField(selection); + const fieldValue = data[resultName]; + + if (Array.isArray(fieldValue)) { + const processed = fieldValue.map((item) => { + const processedItem = coerce(selectionSet, item, fragmentMap); + changed ||= item !== processedItem; + + return processedItem; + }); + + result[resultName] = processed; + + continue; + } else if (selection.selectionSet) { + const processed = coerce( + selection.selectionSet, + fieldValue, + fragmentMap + ); + + changed ||= processed !== fieldValue; + result[resultName] = processed; + + continue; + } + + const typename = + Object.hasOwn(data, "__typename") ? data.__typename : undefined; + + const scalar = + typename && cache.getScalarForField(typename, selection.name.value); + const processed = + scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; + + changed ||= processed !== fieldValue; + result[resultName] = processed; + } else { + const fragment = getFragmentFromSelection(selection, fragmentMap); + + const processed = + fragment ? coerce(fragment.selectionSet, data, fragmentMap) : data; + changed ||= processed !== data; + + Object.assign(result, processed); + } + } + + return changed ? result : data; + }; + + return coerce( + getMainDefinition(query).selectionSet, + result, + createFragmentMap(getFragmentDefinitions(query)) + ); +} 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"; From bf7a0fa2f5232575de49b93dc3334a4cca76adcd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:05:47 -0600 Subject: [PATCH 05/31] Remove unneeded check --- src/core/QueryManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 6a227ea9db8..5ef073a8f8a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1772,7 +1772,6 @@ export class QueryManager { observable: resultsFromLink().pipe( map((notification) => { if ( - notification.source === "network" && notification.kind === "N" && notification.value.data != null ) { From c452cce69d1592a4cca6502f68abe77c4aebf1a1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:12:10 -0600 Subject: [PATCH 06/31] Ensure result keeps __typename --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 46da4931033..e83ff724b23 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -25,6 +25,10 @@ export function coerceScalarFieldsToParsed( const result: Record = {}; let changed = false; + if (Object.hasOwn(data, "__typename")) { + result.__typename = data.__typename; + } + for (const selection of selectionSet.selections) { if (isField(selection)) { const resultName = resultKeyNameFromField(selection); From f8bb441dbc3679a65b81ebaa87b9a640ce92d87b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:16:53 -0600 Subject: [PATCH 07/31] Add test suite for new utility --- .../coerceScalarFieldsToParsed.test.ts | 835 ++++++++++++++++++ 1 file changed, 835 insertions(+) create mode 100644 src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts new file mode 100644 index 00000000000..9f35fe69feb --- /dev/null +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -0,0 +1,835 @@ +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("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("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 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]]), + }, + }); +}); From 7b1af7964241473f34c0ffc92c63c13e989b2986 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:35:19 -0600 Subject: [PATCH 08/31] Handle arrays --- .../internal/coerceScalarFieldsToParsed.ts | 69 ++++++++++++------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index e83ff724b23..133ee611fb9 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -1,4 +1,4 @@ -import type { DocumentNode, SelectionSetNode } from "graphql"; +import type { DocumentNode, FieldNode, SelectionSetNode } from "graphql"; import type { ApolloCache } from "@apollo/client/cache"; @@ -8,18 +8,48 @@ import { getFragmentFromSelection } from "./getFragmentFromSelection.js"; import { getMainDefinition } from "./getMainDefinition.js"; import { isField } from "./isField.js"; import { resultKeyNameFromField } from "./resultKeyNameFromField.js"; -import type { FragmentMap } from "./types/FragmentMap.js"; export function coerceScalarFieldsToParsed( result: Record, query: DocumentNode, cache: ApolloCache ): Record { - const coerce = ( - selectionSet: SelectionSetNode, - data: any, - fragmentMap: FragmentMap - ): any => { + const fragmentMap = createFragmentMap(getFragmentDefinitions(query)); + + function parseValue( + fieldValue: unknown, + typename: string | undefined, + field: FieldNode + ) { + const scalar = + typename && cache.getScalarForField(typename, field.name.value); + + return scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; + } + + function coerceArray(field: FieldNode, array: any[], typename: string) { + const result: any[] = []; + let changed = false; + + for (const item of array) { + let coerced: unknown; + + if (Array.isArray(item)) { + coerced = coerceArray(field, item, typename); + } else if (field.selectionSet) { + coerced = coerceSelectionSet(field.selectionSet, item); + } else { + coerced = parseValue(item, typename, field); + } + + changed ||= coerced !== item; + result.push(coerced); + } + + return changed ? result : array; + } + + function coerceSelectionSet(selectionSet: SelectionSetNode, data: any): any { if (data == null) return data; const result: Record = {}; @@ -35,21 +65,16 @@ export function coerceScalarFieldsToParsed( const fieldValue = data[resultName]; if (Array.isArray(fieldValue)) { - const processed = fieldValue.map((item) => { - const processedItem = coerce(selectionSet, item, fragmentMap); - changed ||= item !== processedItem; - - return processedItem; - }); + const coerced = coerceArray(selection, fieldValue, data.__typename); - result[resultName] = processed; + changed ||= coerced !== fieldValue; + result[resultName] = coerced; continue; } else if (selection.selectionSet) { - const processed = coerce( + const processed = coerceSelectionSet( selection.selectionSet, - fieldValue, - fragmentMap + fieldValue ); changed ||= processed !== fieldValue; @@ -72,7 +97,7 @@ export function coerceScalarFieldsToParsed( const fragment = getFragmentFromSelection(selection, fragmentMap); const processed = - fragment ? coerce(fragment.selectionSet, data, fragmentMap) : data; + fragment ? coerceSelectionSet(fragment.selectionSet, data) : data; changed ||= processed !== data; Object.assign(result, processed); @@ -80,11 +105,7 @@ export function coerceScalarFieldsToParsed( } return changed ? result : data; - }; + } - return coerce( - getMainDefinition(query).selectionSet, - result, - createFragmentMap(getFragmentDefinitions(query)) - ); + return coerceSelectionSet(getMainDefinition(query).selectionSet, result); } From a320a347ab561cdd350aa8e3b078a4a46b9f99c5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:40:32 -0600 Subject: [PATCH 09/31] Minor refactoring to reduce redundancy --- .../internal/coerceScalarFieldsToParsed.ts | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 133ee611fb9..cf92af40c72 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -27,7 +27,11 @@ export function coerceScalarFieldsToParsed( return scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; } - function coerceArray(field: FieldNode, array: any[], typename: string) { + function coerceArray( + field: FieldNode, + array: any[], + typename: string | undefined + ) { const result: any[] = []; let changed = false; @@ -54,9 +58,10 @@ export function coerceScalarFieldsToParsed( const result: Record = {}; let changed = false; + let typename: string | undefined; if (Object.hasOwn(data, "__typename")) { - result.__typename = data.__typename; + typename = result.__typename = data.__typename; } for (const selection of selectionSet.selections) { @@ -64,35 +69,17 @@ export function coerceScalarFieldsToParsed( const resultName = resultKeyNameFromField(selection); const fieldValue = data[resultName]; + let coerced: unknown; if (Array.isArray(fieldValue)) { - const coerced = coerceArray(selection, fieldValue, data.__typename); - - changed ||= coerced !== fieldValue; - result[resultName] = coerced; - - continue; + coerced = coerceArray(selection, fieldValue, typename); } else if (selection.selectionSet) { - const processed = coerceSelectionSet( - selection.selectionSet, - fieldValue - ); - - changed ||= processed !== fieldValue; - result[resultName] = processed; - - continue; + coerced = coerceSelectionSet(selection.selectionSet, fieldValue); + } else { + coerced = parseValue(fieldValue, typename, selection); } - const typename = - Object.hasOwn(data, "__typename") ? data.__typename : undefined; - - const scalar = - typename && cache.getScalarForField(typename, selection.name.value); - const processed = - scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; - - changed ||= processed !== fieldValue; - result[resultName] = processed; + changed ||= coerced !== fieldValue; + result[resultName] = coerced; } else { const fragment = getFragmentFromSelection(selection, fragmentMap); From 34df76d4b6dfd5fdb207c55a089005c23978cd39 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:41:57 -0600 Subject: [PATCH 10/31] Rename function --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index cf92af40c72..50493f127d1 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -16,7 +16,7 @@ export function coerceScalarFieldsToParsed( ): Record { const fragmentMap = createFragmentMap(getFragmentDefinitions(query)); - function parseValue( + function coerce( fieldValue: unknown, typename: string | undefined, field: FieldNode @@ -43,7 +43,7 @@ export function coerceScalarFieldsToParsed( } else if (field.selectionSet) { coerced = coerceSelectionSet(field.selectionSet, item); } else { - coerced = parseValue(item, typename, field); + coerced = coerce(item, typename, field); } changed ||= coerced !== item; @@ -75,7 +75,7 @@ export function coerceScalarFieldsToParsed( } else if (selection.selectionSet) { coerced = coerceSelectionSet(selection.selectionSet, fieldValue); } else { - coerced = parseValue(fieldValue, typename, selection); + coerced = coerce(fieldValue, typename, selection); } changed ||= coerced !== fieldValue; From dfe64cedfca7899bb485e605738d75e6024dcf00 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:43:45 -0600 Subject: [PATCH 11/31] Handle null scalar values --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 50493f127d1..270339d3380 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -21,6 +21,8 @@ export function coerceScalarFieldsToParsed( typename: string | undefined, field: FieldNode ) { + if (fieldValue === null) return null; + const scalar = typename && cache.getScalarForField(typename, field.name.value); @@ -54,7 +56,7 @@ export function coerceScalarFieldsToParsed( } function coerceSelectionSet(selectionSet: SelectionSetNode, data: any): any { - if (data == null) return data; + if (data === null) return null; const result: Record = {}; let changed = false; From 663f6b4783284d04c498cec1a6e8c4f2fded12a7 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:44:16 -0600 Subject: [PATCH 12/31] Rename variable --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 270339d3380..2c6e6c7f6e1 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -85,11 +85,11 @@ export function coerceScalarFieldsToParsed( } else { const fragment = getFragmentFromSelection(selection, fragmentMap); - const processed = + const coerced = fragment ? coerceSelectionSet(fragment.selectionSet, data) : data; - changed ||= processed !== data; + changed ||= coerced !== data; - Object.assign(result, processed); + Object.assign(result, coerced); } } From 3eed48077d41417926972cdfe77b92bbadc9d702 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:47:43 -0600 Subject: [PATCH 13/31] Handle type conditions --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 2c6e6c7f6e1..a856a20427e 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -84,12 +84,14 @@ export function coerceScalarFieldsToParsed( result[resultName] = coerced; } else { const fragment = getFragmentFromSelection(selection, fragmentMap); + let coerced = data; - const coerced = - fragment ? coerceSelectionSet(fragment.selectionSet, data) : data; - changed ||= coerced !== data; + if (fragment && typename && cache.fragmentMatches(fragment, typename)) { + coerced = coerceSelectionSet(fragment.selectionSet, data); + Object.assign(result, coerced); + } - Object.assign(result, coerced); + changed ||= coerced !== data; } } From 0f568ff1f4955bdd573a00ce2a8a38e28b63e701 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 01:53:50 -0600 Subject: [PATCH 14/31] Add more test cases for fragments --- .../coerceScalarFieldsToParsed.test.ts | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts index 9f35fe69feb..8a7f9b2ed20 100644 --- a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -501,6 +501,234 @@ test("parses custom scalar fields selected by an inline fragment", () => { }); }); +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("parses custom scalar 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 }, From 4da78e5ef9ddd989acbf6d22fb444e1f8f8d9242 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 02:14:07 -0600 Subject: [PATCH 15/31] Add a getRootTypename method to the cache --- src/cache/core/cache.ts | 5 +++++ src/cache/inmemory/inMemoryCache.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index bd44a6b3736..6dc29e9beeb 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) { + return operation[0].toUpperCase() + operation.slice(1); + } + // Custom scalars API public getScalar( diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 08764ba051b..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 ( From 0fb5a2e3422a401c385e3a6298d86b8a2970f823 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 02:15:04 -0600 Subject: [PATCH 16/31] Handle root fields --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index a856a20427e..a1c255a372d 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -1,11 +1,13 @@ 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"; @@ -14,8 +16,16 @@ export function coerceScalarFieldsToParsed( 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" + ); + + const rootTypename = cache.getRootTypename(operationType); + function coerce( fieldValue: unknown, typename: string | undefined, @@ -60,7 +70,7 @@ export function coerceScalarFieldsToParsed( const result: Record = {}; let changed = false; - let typename: string | undefined; + let typename = rootTypename; if (Object.hasOwn(data, "__typename")) { typename = result.__typename = data.__typename; From f7d778516d944ea6faaa5af472e808ffb1c604b1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 02:17:30 -0600 Subject: [PATCH 17/31] Drop check for typename --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index a1c255a372d..4c0404a8bcd 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -96,7 +96,7 @@ export function coerceScalarFieldsToParsed( const fragment = getFragmentFromSelection(selection, fragmentMap); let coerced = data; - if (fragment && typename && cache.fragmentMatches(fragment, typename)) { + if (fragment && cache.fragmentMatches(fragment, typename)) { coerced = coerceSelectionSet(fragment.selectionSet, data); Object.assign(result, coerced); } From 2f676c687f1b790fe316160e7d3e22d366b474d0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 02:26:04 -0600 Subject: [PATCH 18/31] Add additional test for objects without typename --- .../coerceScalarFieldsToParsed.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts index 8a7f9b2ed20..d4ff4db4f5d 100644 --- a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -87,6 +87,42 @@ test("parses custom scalar fields when the query selects __typename", () => { }); }); +test("does not parse fields on objects without a __typename", () => { + const cache = new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Query: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }); + + const query = gql` + query Event { + event { + id + startDate + } + } + `; + + const result = { + event: { + id: "1", + startDate: "2026-01-01", + }, + }; + + expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ + event: { + id: "1", + startDate: "2026-01-01", + }, + }); +}); + test("parses custom scalar fields on root fields", () => { const cache = new InMemoryCache({ scalars: { Date: dateScalar }, From a68041e0f8707bb5dd28d57387b291fdd41cd782 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 09:47:36 -0600 Subject: [PATCH 19/31] Traverse fragment instead of matching and ignore fields not on object --- .../coerceScalarFieldsToParsed.test.ts | 2 +- .../internal/coerceScalarFieldsToParsed.ts | 78 +++++++++++-------- 2 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts index d4ff4db4f5d..6fd3fdfca0d 100644 --- a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -721,7 +721,7 @@ test("parses custom scalar fields selected by a fragment on an interface type", }); }); -test("parses custom scalar fields selected by a fragment on a supertype missing from possibleTypes", () => { +test("does not drop fields selected by a fragment on a supertype missing from possibleTypes", () => { const cache = new InMemoryCache({ scalars: { Date: dateScalar }, typePolicies: { diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 4c0404a8bcd..d705a758278 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -31,11 +31,9 @@ export function coerceScalarFieldsToParsed( typename: string | undefined, field: FieldNode ) { - if (fieldValue === null) return null; - - const scalar = - typename && cache.getScalarForField(typename, field.name.value); + if (fieldValue === null || !typename) return fieldValue; + const scalar = cache.getScalarForField(typename, field.name.value); return scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; } @@ -53,7 +51,7 @@ export function coerceScalarFieldsToParsed( if (Array.isArray(item)) { coerced = coerceArray(field, item, typename); } else if (field.selectionSet) { - coerced = coerceSelectionSet(field.selectionSet, item); + coerced = coerceSelectionSet(field.selectionSet, item, typename); } else { coerced = coerce(item, typename, field); } @@ -65,48 +63,62 @@ export function coerceScalarFieldsToParsed( return changed ? result : array; } - function coerceSelectionSet(selectionSet: SelectionSetNode, data: any): any { - if (data === null) return null; + function coerceSelectionSet( + selectionSet: SelectionSetNode, + data: any, + typename: string | undefined + ): any { + if (data === null || typeof data !== "object") return data; - const result: Record = {}; + const result: Record = { ...data }; let changed = false; - let typename = rootTypename; if (Object.hasOwn(data, "__typename")) { typename = result.__typename = data.__typename; } - for (const selection of selectionSet.selections) { - if (isField(selection)) { - const resultName = resultKeyNameFromField(selection); - const fieldValue = data[resultName]; - - let coerced: unknown; - if (Array.isArray(fieldValue)) { - coerced = coerceArray(selection, fieldValue, typename); - } else if (selection.selectionSet) { - coerced = coerceSelectionSet(selection.selectionSet, fieldValue); + function visit(selectionSet: SelectionSetNode) { + for (const selection of selectionSet.selections) { + if (isField(selection)) { + const resultName = resultKeyNameFromField(selection); + + if (!Object.hasOwn(data, resultName)) continue; + + const fieldValue = data[resultName]; + + let coerced: unknown; + if (Array.isArray(fieldValue)) { + coerced = coerceArray(selection, fieldValue, data.__typename); + } else if (selection.selectionSet) { + coerced = coerceSelectionSet( + selection.selectionSet, + fieldValue, + data.__typename + ); + } else { + coerced = coerce(fieldValue, typename, selection); + } + + changed ||= coerced !== fieldValue; + result[resultName] = coerced; } else { - coerced = coerce(fieldValue, typename, selection); - } + const fragment = getFragmentFromSelection(selection, fragmentMap); - changed ||= coerced !== fieldValue; - result[resultName] = coerced; - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - let coerced = data; - - if (fragment && cache.fragmentMatches(fragment, typename)) { - coerced = coerceSelectionSet(fragment.selectionSet, data); - Object.assign(result, coerced); + if (fragment) { + visit(fragment.selectionSet); + } } - - changed ||= coerced !== data; } } + visit(selectionSet); + return changed ? result : data; } - return coerceSelectionSet(getMainDefinition(query).selectionSet, result); + return coerceSelectionSet( + getMainDefinition(query).selectionSet, + result, + rootTypename + ); } From fd64266944818b46b082432f267e4e9eba82bc11 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:17:34 -0600 Subject: [PATCH 20/31] Inline root typename --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index d705a758278..2fc0e986c7d 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -24,8 +24,6 @@ export function coerceScalarFieldsToParsed( "Document node must be a query, mutation, or subscription" ); - const rootTypename = cache.getRootTypename(operationType); - function coerce( fieldValue: unknown, typename: string | undefined, @@ -119,6 +117,6 @@ export function coerceScalarFieldsToParsed( return coerceSelectionSet( getMainDefinition(query).selectionSet, result, - rootTypename + cache.getRootTypename(operationType) ); } From 2ef0119ddfecea16aac6988f376db01cb0dafd5f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:20:05 -0600 Subject: [PATCH 21/31] Add another edge case for objects without typenames --- .../coerceScalarFieldsToParsed.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts index 6fd3fdfca0d..824ac710185 100644 --- a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -96,15 +96,26 @@ test("does not parse fields on objects without a __typename", () => { startDate: { scalar: "Date" }, }, }, + Conference: { + fields: { + openedAt: { scalar: "Date" }, + }, + }, }, }); const query = gql` - query Event { + query EventAndConference { event { id startDate } + conference { + id + venue { + openedAt + } + } } `; @@ -113,6 +124,13 @@ test("does not parse fields on objects without a __typename", () => { id: "1", startDate: "2026-01-01", }, + conference: { + __typename: "Conference", + id: "2", + venue: { + openedAt: "2026-02-02", + }, + }, }; expect(coerceScalarFieldsToParsed(result, query, cache)).toStrictEqualTyped({ @@ -120,6 +138,13 @@ test("does not parse fields on objects without a __typename", () => { id: "1", startDate: "2026-01-01", }, + conference: { + __typename: "Conference", + id: "2", + venue: { + openedAt: "2026-02-02", + }, + }, }); }); From 5de3f2fe04d982a0f8083a785ca78b41c023c162 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:24:17 -0600 Subject: [PATCH 22/31] Remove redundant assignment --- src/utilities/internal/coerceScalarFieldsToParsed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 2fc0e986c7d..221b474f46f 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -72,7 +72,7 @@ export function coerceScalarFieldsToParsed( let changed = false; if (Object.hasOwn(data, "__typename")) { - typename = result.__typename = data.__typename; + typename = data.__typename; } function visit(selectionSet: SelectionSetNode) { From 9d48a8b030a69eb4ae1982213221e0c7b6ae716c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:30:00 -0600 Subject: [PATCH 23/31] Reduce duplication in field processing --- .../internal/coerceScalarFieldsToParsed.ts | 61 ++++++------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 221b474f46f..126d86af56e 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -24,47 +24,38 @@ export function coerceScalarFieldsToParsed( "Document node must be a query, mutation, or subscription" ); - function coerce( - fieldValue: unknown, - typename: string | undefined, - field: FieldNode - ) { - if (fieldValue === null || !typename) return fieldValue; - - const scalar = cache.getScalarForField(typename, field.name.value); - return scalar ? scalar.coerceToParsed(fieldValue) : fieldValue; - } - - function coerceArray( + function coerceField( field: FieldNode, - array: any[], + fieldValue: unknown, typename: string | undefined - ) { - const result: any[] = []; + ): unknown { let changed = false; - for (const item of array) { - let coerced: unknown; + if (Array.isArray(fieldValue)) { + const items = fieldValue.map((item) => { + const coerced = coerceField(field, item, typename); - if (Array.isArray(item)) { - coerced = coerceArray(field, item, typename); - } else if (field.selectionSet) { - coerced = coerceSelectionSet(field.selectionSet, item, typename); - } else { - coerced = coerce(item, typename, field); - } + changed ||= coerced !== item; + return coerced; + }); - changed ||= coerced !== item; - result.push(coerced); + return changed ? items : fieldValue; } - return changed ? result : array; + 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 | undefined + typename?: string ): any { if (data === null || typeof data !== "object") return data; @@ -83,19 +74,7 @@ export function coerceScalarFieldsToParsed( if (!Object.hasOwn(data, resultName)) continue; const fieldValue = data[resultName]; - - let coerced: unknown; - if (Array.isArray(fieldValue)) { - coerced = coerceArray(selection, fieldValue, data.__typename); - } else if (selection.selectionSet) { - coerced = coerceSelectionSet( - selection.selectionSet, - fieldValue, - data.__typename - ); - } else { - coerced = coerce(fieldValue, typename, selection); - } + const coerced = coerceField(selection, fieldValue, typename); changed ||= coerced !== fieldValue; result[resultName] = coerced; From 9dcd60c4572935dbd4fdfe74b719c37272f8863f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:32:23 -0600 Subject: [PATCH 24/31] Use workSet to iterate selections --- .../internal/coerceScalarFieldsToParsed.ts | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/utilities/internal/coerceScalarFieldsToParsed.ts b/src/utilities/internal/coerceScalarFieldsToParsed.ts index 126d86af56e..c860993e10b 100644 --- a/src/utilities/internal/coerceScalarFieldsToParsed.ts +++ b/src/utilities/internal/coerceScalarFieldsToParsed.ts @@ -66,29 +66,24 @@ export function coerceScalarFieldsToParsed( typename = data.__typename; } - function visit(selectionSet: SelectionSetNode) { - for (const selection of selectionSet.selections) { - if (isField(selection)) { - const resultName = resultKeyNameFromField(selection); - - if (!Object.hasOwn(data, resultName)) continue; - - const fieldValue = data[resultName]; - const coerced = coerceField(selection, fieldValue, typename); - - changed ||= coerced !== fieldValue; - result[resultName] = coerced; - } else { - const fragment = getFragmentFromSelection(selection, fragmentMap); - - if (fragment) { - visit(fragment.selectionSet); - } - } + 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)); } - } - - visit(selectionSet); + }); return changed ? result : data; } From b24b2d4b4dbe4d0e1af09731e37285fab1c7f6ae Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:48:02 -0600 Subject: [PATCH 25/31] Don't assume root typename for 3rd party caches --- src/cache/core/cache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 6dc29e9beeb..7701ecde429 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -264,8 +264,8 @@ export abstract class ApolloCache { return null; } - public getRootTypename(operation: OperationTypeNode) { - return operation[0].toUpperCase() + operation.slice(1); + public getRootTypename(operation: OperationTypeNode): string | undefined { + return; } // Custom scalars API From 6363d136e4b4d075e7e315957b6a8cb02bf8c70c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:53:38 -0600 Subject: [PATCH 26/31] Add more test cases --- .../coerceScalarFieldsToParsed.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts index 824ac710185..b9c2228ca2b 100644 --- a/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts +++ b/src/utilities/internal/__tests__/coerceScalarFieldsToParsed.test.ts @@ -915,6 +915,54 @@ test("leaves null scalar values as-is", () => { }); }); +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 }, @@ -1122,3 +1170,140 @@ test("parses scalars that serialize to object values", () => { }, }); }); + +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); +}); From 957ab2b2335c4c6b6af2f228ef4ea118e913ced9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:54:49 -0600 Subject: [PATCH 27/31] Add changeset --- .changeset/nasty-keys-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nasty-keys-brush.md 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. From d8071736916404ea4c9409c4a8c80e4e489f0219 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:56:42 -0600 Subject: [PATCH 28/31] Update exports snapshot --- src/__tests__/__snapshots__/exports.ts.snap | 1 + 1 file changed, 1 insertion(+) 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", From 770e224a07eb815d9aa83486c8c62f5e30af414b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:57:15 -0600 Subject: [PATCH 29/31] Remove unused imports --- src/core/QueryManager.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 5ef073a8f8a..04ec94c2ebb 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -4,7 +4,6 @@ import type { DocumentNode, FormattedExecutionResult, } from "graphql"; -import type { SelectionSetNode } from "graphql"; import { BREAK, Kind, OperationTypeNode, visit } from "graphql"; import { Observable, throwError } from "rxjs"; import { @@ -44,37 +43,28 @@ import type { DeepPartial } from "@apollo/client/utilities"; import { cacheSizes, DocumentTransform, - getMainDefinition, isNetworkRequestInFlight, print, } from "@apollo/client/utilities"; import { __DEV__ } from "@apollo/client/utilities/environment"; -import type { - ExtensionsWithStreamInfo, - FragmentMap, -} from "@apollo/client/utilities/internal"; +import type { ExtensionsWithStreamInfo } from "@apollo/client/utilities/internal"; import { AutoCleanedWeakCache, checkDocument, coerceScalarFieldsToParsed, - createFragmentMap, extensionsSymbol, filterMap, getDefaultValues, - getFragmentDefinitions, - getFragmentFromSelection, getOperationDefinition, getOperationName, graphQLResultHasError, hasDirectives, hasForcedResolvers, isDocumentNode, - isField, isNonNullObject, makeUniqueId, mergeOptions, removeDirectivesFromDocument, - resultKeyNameFromField, streamInfoSymbol, toQueryResult, } from "@apollo/client/utilities/internal"; From 2bc758717778218067a27f80eadc02a7b50a5b60 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 10:59:22 -0600 Subject: [PATCH 30/31] Update api report --- .api-reports/api-report-cache.api.md | 11 +++++++++-- .api-reports/api-report-core.api.md | 2 +- .api-reports/api-report-utilities_internal.api.md | 8 ++++++-- .api-reports/api-report.api.md | 12 +++++++++--- 4 files changed, 25 insertions(+), 8 deletions(-) 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 From 734c0c9270290b5147ba1a03ed379d7d86b6fa63 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 29 Jul 2026 11:09:52 -0600 Subject: [PATCH 31/31] Remove unneeded .failing --- .../client.query/customScalars.test.ts | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) 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`