Skip to content

Custom scalars#13270

Merged
jerelmiller merged 7 commits into
release-4.3from
feat/custom-scalars
Jun 30, 2026
Merged

Custom scalars#13270
jerelmiller merged 7 commits into
release-4.3from
feat/custom-scalars

Conversation

@jerelmiller

@jerelmiller jerelmiller commented Jun 10, 2026

Copy link
Copy Markdown
Member

Feature branch for custom scalars.

Summary by CodeRabbit

  • New Features
    • Added support for custom GraphQL scalars in the cache, including serialization, parsing, and field-level scalar mapping.
    • Added support for nested input object scalar configuration.
    • Expanded cache behavior so scalar values are handled consistently across reads, writes, diffs, queries, mutations, subscriptions, fragments, and refetch flows.
  • Bug Fixes
    • Improved consistency when cached scalar values are restored or extracted.
    • Updated variable handling so scalar values are serialized before requests are sent.

…ApolloCache` (#13252)

_Part of the custom scalars work:
https://github.com/apollographql/apollo-client/issues/13227_

Adds the plumbing needed for types in `ApolloCache` as well as
`InMemoryCache` to handle custom scalars. Implements the `getScalar`
method that will be used by this feature to convert a raw value to a
scalar.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

* **New Features**
* Added custom GraphQL scalar support to InMemoryCache with type-safe
configuration for parse, serialize, type-guard, and devtools
integration.
* Added `getScalar()` method to retrieve configured scalar instances by
name.

* **Documentation**
* Added guides for declaring and configuring custom scalars with usage
examples.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@changeset-bot

changeset-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 12960d8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@apollo-librarian

apollo-librarian Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

✅ Docs preview ready

The preview is ready to be viewed. View the preview

File Changes

0 new, 1 changed, 0 removed
* (developer-tools)/react/(latest)/migrating/apollo-client-4-migration.mdx

Build ID: 523ecfd063c949ea6b9d9dcd
Build Logs: View logs

URL: https://www.apollographql.com/docs/deploy-preview/523ecfd063c949ea6b9d9dcd


✅ AI Style Review — No Changes Detected

No MDX files were changed in this pull request.

Review Log: View detailed log

This review is AI-generated. Please use common sense when accepting these suggestions, as they may not always be accurate or appropriate for your specific context.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds first-class custom scalar support to Apollo Client. It introduces a generic Scalar<TSerialized, TParsed> class, wires it into ApolloCache and InMemoryCache (new getScalar, serializeVariables, and conditional constructor), propagates serialization through EntityStore.merge/extract, QueryManager, and QueryInfo, and validates behavior with unit tests, full client-level behavioral tests for every hook and operation type, and TypeScript type tests across 12 scalar-configuration scenarios.

Changes

Custom GraphQL Scalar Support

Layer / File(s) Summary
Scalar class and utility types
src/cache/core/Scalar.ts, src/utilities/internal/types/IsLooselyEqual.ts, src/utilities/internal/getUnwrappedType.ts, src/utilities/internal/index.ts, .api-reports/api-report-utilities_internal.api.md
Adds the generic Scalar<TSerialized, TParsed> class with Options interface, fromGraphQLScalarType factory, coerceToParsed/coerceToSerialized helpers, the IsLooselyEqual<A,B> conditional type, and the getUnwrappedType GraphQL node unwrapper.
ApolloCache base scalar API
src/cache/core/cache.ts, src/cache/core/__tests__/cache.ts, .api-reports/api-report-cache.api.md, .api-reports/api-report.api.md
Extends ApolloCache namespace with Scalars registration interface, GetScalarType conditional type, getScalar method, and two serializeVariables overloads (default returns variables unchanged); adds matching tests asserting the default returns undefined.
InMemoryCache config types, Policies, and field policy scalar
src/cache/inmemory/types.ts, src/cache/inmemory/policies.ts, src/cache/inmemory/readFromStore.ts, .api-reports/api-report-cache.api.md, .api-reports/api-report.api.md
Refactors InMemoryCacheConfig from an interface to a conditional type intersection; adds InputObjectConfig, InputObjectsOption, KnownScalars, ScalarNames; extends FieldPolicy with scalar?: ScalarNames; adds Policies.getScalarForField; adds a dev-time warning in readFromStore for scalar/selection-set mismatches.
InMemoryCache implementation and EntityStore coercion
src/cache/inmemory/inMemoryCache.ts, src/cache/inmemory/entityStore.ts
Updates constructor to conditional rest-parameter form; implements getScalar and recursive serializeVariables (arrays, scalars, input objects); wires serialization into read, write, and diff; adds EntityStore.merge coercion of incoming parsed values and EntityStore.extract serialization via coerceStoreObject/coerceValue helpers.
QueryManager and QueryInfo network serialization
src/core/QueryManager.ts, src/core/QueryInfo.ts
Annotates QueryManager.cache with Cache.Implementation; serializes variables via cache.serializeVariables before storing mutation variables and before sending operations through the link; adds cache re-read after mutation and subscription writes to propagate parsed scalar values into result.data.
Public exports and global scalar declarations
src/cache/index.ts, src/core/index.ts, src/declarations.d.ts, .api-reports/api-report-core.api.md
Re-exports Scalar, InputObjectConfig, InputObjectsOption from cache and core entry points; adds the default ApolloCache.Scalars index-signature declaration to declarations.d.ts.
Test helpers and infrastructure
src/testing/internal/scalars.ts, src/testing/internal/index.ts, src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx, config/jest.config.ts
Adds dateScalar, dateTimeScalar, priceScalar, jsonObjectScalar test Scalar instances; adds renderDefaultTestApp query-preloader harness; updates Jest config to exclude new utility files from React 17 runs.
Cache-level behavioral tests
src/cache/inmemory/__tests__/variablesSerialization.ts, src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts, src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts, src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts, src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts
Adds 1 400+ line serializeVariables unit test covering scalars, null/reference preservation, arrays, input objects, recursive structures, and mutation prevention; adds InMemoryCache diff/read/write/readFragment tests for field args, directive args, input objects, and scalar field parsing.
Client-level behavioral tests
src/core/__tests__/client.*/customScalars.test.ts
Adds customScalars test suites for client.query, client.mutate (including optimistic responses, update callbacks, refetchQueries), client.readQuery, client.readFragment, client.writeQuery, client.refetchQueries, client.subscribe, client.watchFragment, and client.watchQuery, covering all fetch policies.
React hooks behavioral tests
src/react/hooks/__tests__/use*/customScalars.test.tsx, src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx
Adds customScalars suites for useQuery, useLazyQuery, useMutation, useSubscription, useFragment, useSuspenseFragment, useSuspenseQuery, useBackgroundQuery, useLoadableQuery, useQueryRefHandlers, and createQueryPreloader, verifying variable serialization and scalar field parsing across all fetch policies and lifecycle operations.
TypeScript integration type tests
integration-tests/type-tests/customScalars/..., integration-tests/type-tests/tsconfig.json
Adds 12 type-test suites (empty/matchingTypes/differentTypes/mixed × no-index/all-any/all-unknown/all-structured) verifying compile-time enforcement of InMemoryCache constructor scalar requirements, Scalar callback typing, getScalar return types, and field policy scalar name constraints.
Changelogs, API reports, and CI config
.changeset/*.md, package.json, .circleci/config.yml
Adds four changeset entries documenting new scalar APIs; splits the lint npm script into lint:docs and lint:src in package.json and CircleCI.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant ApolloClient
  participant QueryManager
  participant InMemoryCache
  participant serializeVariables
  participant EntityStore
  participant NetworkLink

  App->>ApolloClient: query/mutate/subscribe(options with Date variables)
  ApolloClient->>QueryManager: execute operation
  QueryManager->>InMemoryCache: serializeVariables(document, variables)
  InMemoryCache->>serializeVariables: recurse scalars/arrays/inputObjects
  serializeVariables-->>QueryManager: {date: "2026-01-01"} (serialized)
  QueryManager->>NetworkLink: forward operation (serialized vars)
  NetworkLink-->>QueryManager: {startDate: "2026-01-01"} response
  QueryManager->>InMemoryCache: write(response)
  InMemoryCache->>EntityStore: merge → coerceToParsed (string → Date)
  EntityStore-->>InMemoryCache: stored as Date
  InMemoryCache->>EntityStore: diff/read → getScalarForField
  EntityStore-->>App: {startDate: Date(2026,0,1)} parsed result
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

  • RFC: Custom scalars #13227: This PR directly implements the custom scalar cache APIs, TypeScript declaration merging, serialization/parsing pipeline, and inputObjects configuration proposed in that RFC.

Possibly related PRs

  • apollographql/apollo-client#13250: Both PRs update QueryManager.cache to return Cache_2.Implementation (the overridable cache type work).
  • apollographql/apollo-client#13252: Shares the same custom-scalar type plumbing additions — ApolloCache.getScalar/GetScalarType, InMemoryCache.getScalar, and IsLooselyEqual — making this a direct predecessor or sibling PR.
  • apollographql/apollo-client#13273: Builds on the same ApolloCache/InMemoryCache.serializeVariables surface and InMemoryCacheConfig inputObjects plumbing introduced here.

Suggested reviewers

  • phryneas
  • DaleSeo

Poem

🐇 Hop, hop, I parse your Date!
No more raw strings passed too late.
serialize goes out the door,
parse brings objects back once more.
Custom scalars, typed just right —
Apollo cache shines extra bright! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is relevant, but it is too vague to clearly describe the main change in the pull request. Use a more specific title that mentions Apollo Client custom scalar support, such as adding cache scalar typing or serialization support.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

config/jest.config.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

integration-tests/type-tests/customScalars/all-any/empty/index.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

  • 58 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…13259)

_Part of the custom scalars work:
https://github.com/apollographql/apollo-client/issues/13227_

Adds scalar value serialization for scalar fields during cache writes.
This allows users to provide either the parsed or serialized value to
the cache and have it work as expected.
@jerelmiller jerelmiller force-pushed the feat/custom-scalars branch from ccaf686 to 6031987 Compare June 10, 2026 16:35
Fixes the build issue on the custom scalars feature branch.
@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@apollo/client@13270

commit: 12960d8

Adds a new `Scalar.fromGraphQLScalarType` helper that is used to create
a `Scalar` instance from an existing `GraphQLScalarType` instance from
the `graphql` package. This makes it easier to use scalar definitions
from packages such as
[`graphql-scalars`](https://the-guild.dev/graphql/scalars)
_Part of the custom scalars work:
https://github.com/apollographql/apollo-client/issues/13227_

Adds automatic variable serialization for custom scalars.

> [!NOTE]
> This PR does not support the fragment APIs (e.g. `writeFragment`,
`readFragment`, `watchFragment`. Fragment definitions don't have
variable definitions and thus cannot be serialized. A followup PR will
be opened to address this in some form.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Automatic serialization of custom scalar values in GraphQL operation
variables across queries, mutations, subscriptions, and cache operations
* New `inputObjects` configuration option for InMemoryCache to specify
scalar field locations within complex input object types

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…l fetch policies (#13275)

Adds tests for all APIs to test parsing scalars for all fetch policies.
This revealed an issue with mutations and subscriptions where the parsed
scalar value wasn't used correctly. This has been fixed in this PR.
@jerelmiller

Copy link
Copy Markdown
Member Author

This is in a good enough place that we can start getting some alpha testing. I'm going to go ahead and get this in the 4.3 branch so we can get an alpha out and start getting feedback.

@jerelmiller jerelmiller marked this pull request as ready for review June 30, 2026 17:32
@jerelmiller jerelmiller force-pushed the feat/custom-scalars branch from 78fbc75 to 12960d8 Compare June 30, 2026 17:41
@jerelmiller jerelmiller enabled auto-merge June 30, 2026 17:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
src/cache/inmemory/entityStore.ts (1)

404-416: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Short-circuit extract when no scalars are configured.

When config.scalars is unset, coerceStoreObject returns each storeObject unchanged, but this still rebuilds the entire entry map (new top-level object + per-entry tuples) on every extract() call — a needless O(n) allocation on a path that can be hot during SSR. Consider falling back to the previous toObject() form when scalar coercion isn't enabled.

♻️ Proposed guard
-    const obj = Object.fromEntries(
-      Object.entries(this.toObject()).map(([dataId, storeObject]) => [
-        dataId,
-        storeObject &&
-          this.coerceStoreObject(
-            storeObject,
-            (scalar, value) => scalar.coerceToSerialized(value),
-            storeObject?.__typename || this.policies.rootTypenamesById[dataId]
-          ),
-      ])
-    );
+    const obj = this.policies.cache["config"].scalars ?
+        Object.fromEntries(
+          Object.entries(this.toObject()).map(([dataId, storeObject]) => [
+            dataId,
+            storeObject &&
+              this.coerceStoreObject(
+                storeObject,
+                (scalar, value) => scalar.coerceToSerialized(value),
+                storeObject?.__typename ||
+                  this.policies.rootTypenamesById[dataId]
+              ),
+          ])
+        )
+      : this.toObject();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/inmemory/entityStore.ts` around lines 404 - 416, The `extract()`
method in `entityStore.ts` always rebuilds a new entry map even when
`config.scalars` is unset and `coerceStoreObject` would be a no-op. Update
`extract()` to short-circuit to the plain `toObject()` result when scalar
coercion is not configured, and only run the `Object.entries(...).map(...)` path
when scalars are available. Use the `extract()` and `coerceStoreObject()` paths
to locate the change.
src/cache/inmemory/readFromStore.ts (1)

364-382: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the already-computed typename instead of re-reading it.

The enclosing execSelectionSetImpl already derives typename from the same objectOrReference (line 294). The new block re-invokes context.store.getFieldValue(..., "__typename") and shadows the outer typename, which is redundant.

♻️ Proposed simplification
           if (__DEV__) {
-            const typename = context.store.getFieldValue<string>(
-              objectOrReference,
-              "__typename"
-            );
             const fieldName = selection.name.value;

             if (typename) {
               const policy = policies["getFieldPolicy"](typename, fieldName);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/inmemory/readFromStore.ts` around lines 364 - 382, The new __DEV__
warning block in readFromStore should reuse the already-derived typename from
execSelectionSetImpl instead of calling context.store.getFieldValue(...,
"__typename") again and shadowing it. Update the selection handling in
execSelectionSetImpl to use the existing typename variable when looking up the
policy and emitting the warning, keeping the logic in the same block but
removing the redundant read.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts`:
- Around line 5-10: The ApolloCache.Scalars augmentation is too strict because
the Record<string, { serialized: string; parsed: string }> index signature
forces every scalar, including DateTime, to use parsed: string and prevents the
intended cache-type mismatch from being tested. Update the custom scalar
declaration around ApolloCache.Scalars so the index value type is widened enough
for DateTime to fit, or move the conflicting DateTime shape into the
InMemoryCache assertion instead, keeping the mismatch where the test is meant to
validate it.

In `@src/cache/core/Scalar.ts`:
- Around line 51-56: The fallback implementation of Scalar.is is guessing the
runtime shape and breaks scalar pairs with overlapping or primitive
serialized/parsed types. Update Scalar’s runtime discrimination in the Scalar
class so it does not rely on typeof value === "object" when no explicit
predicate is provided; instead, require Scalar.Options.is for ambiguous cases or
make the default behavior conservative enough that coerceToParsed and
coerceToSerialized don’t misclassify values. Use the existing Scalar.is,
coerceToParsed, and coerceToSerialized logic to ensure already-parsed values and
serialized values are handled correctly without silent runtime swapping.

In `@src/core/__tests__/client.watchQuery/customScalars.test.ts`:
- Around line 859-922: The no-cache watchQuery path is still bypassing custom
scalar parsing, so the result stays serialized instead of being converted
through the scalar runtime. Update the watchQuery/no-cache handling in
ApolloClient and the related result processing so fields configured via
InMemoryCache typePolicies and scalars are parsed consistently before emitting
values, using the existing custom scalar plumbing around watchQuery and
ObservableStream. After the fix, this customScalars test should no longer need
test.failing and should pass with a Date value for startDate.

In `@src/react/hooks/__tests__/useMutation/customScalars.test.tsx`:
- Around line 391-447: The no-cache path in useMutation still bypasses custom
scalar coercion, so mutation results differ from the cached/network-only flows.
Update the mutation result handling in useMutation (and any helper it uses for
result normalization) so scalar parsing still runs when fetchPolicy is
"no-cache", using the same scalar/type policy logic exercised by the custom
scalar tests and the Date field mapping on Event.startDate. Ensure the mutate()
result shape stays consistent with the existing parsed behavior.

In `@src/react/hooks/__tests__/useQuery/customScalars.test.tsx`:
- Around line 633-698: The no-cache query result path is still skipping custom
scalar parsing, so `useQuery` returns wire-format values instead of runtime
scalar values. Update the underlying `QueryManager`/`QueryInfo` no-cache flow so
it applies the same scalar coercion used by other fetch policies, and verify the
fix against `useQuery`, `ApolloClient`, and the `Date` scalar setup in
`customScalars.test.tsx`. Avoid keeping this as `test.failing`; the expected
behavior should be that `fetchPolicy: "no-cache"` returns parsed scalar values
too.

---

Nitpick comments:
In `@src/cache/inmemory/entityStore.ts`:
- Around line 404-416: The `extract()` method in `entityStore.ts` always
rebuilds a new entry map even when `config.scalars` is unset and
`coerceStoreObject` would be a no-op. Update `extract()` to short-circuit to the
plain `toObject()` result when scalar coercion is not configured, and only run
the `Object.entries(...).map(...)` path when scalars are available. Use the
`extract()` and `coerceStoreObject()` paths to locate the change.

In `@src/cache/inmemory/readFromStore.ts`:
- Around line 364-382: The new __DEV__ warning block in readFromStore should
reuse the already-derived typename from execSelectionSetImpl instead of calling
context.store.getFieldValue(..., "__typename") again and shadowing it. Update
the selection handling in execSelectionSetImpl to use the existing typename
variable when looking up the policy and emitting the warning, keeping the logic
in the same block but removing the redundant read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: a80a52ab-f084-4059-b5c8-6697434ebc89

📥 Commits

Reviewing files that changed from the base of the PR and between 34018db and 12960d8.

⛔ Files ignored due to path filters (1)
  • src/__tests__/__snapshots__/exports.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (90)
  • .api-reports/api-report-cache.api.md
  • .api-reports/api-report-core.api.md
  • .api-reports/api-report-utilities_internal.api.md
  • .api-reports/api-report.api.md
  • .changeset/cold-comics-add.md
  • .changeset/eighty-files-sort.md
  • .changeset/strong-shoes-sell.md
  • .changeset/thin-tips-fold.md
  • .changeset/wet-experts-end.md
  • .circleci/config.yml
  • config/jest.config.ts
  • integration-tests/type-tests/customScalars/all-any/differentTypes/index.ts
  • integration-tests/type-tests/customScalars/all-any/differentTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-any/empty/index.ts
  • integration-tests/type-tests/customScalars/all-any/empty/tsconfig.json
  • integration-tests/type-tests/customScalars/all-any/matchingTypes/index.ts
  • integration-tests/type-tests/customScalars/all-any/matchingTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-any/mixed/index.ts
  • integration-tests/type-tests/customScalars/all-any/mixed/tsconfig.json
  • integration-tests/type-tests/customScalars/all-structured/differentTypes/index.ts
  • integration-tests/type-tests/customScalars/all-structured/differentTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-structured/empty/index.ts
  • integration-tests/type-tests/customScalars/all-structured/empty/tsconfig.json
  • integration-tests/type-tests/customScalars/all-structured/matchingTypes/index.ts
  • integration-tests/type-tests/customScalars/all-structured/matchingTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-structured/mixed/index.ts
  • integration-tests/type-tests/customScalars/all-structured/mixed/tsconfig.json
  • integration-tests/type-tests/customScalars/all-unknown/differentTypes/index.ts
  • integration-tests/type-tests/customScalars/all-unknown/differentTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-unknown/empty/index.ts
  • integration-tests/type-tests/customScalars/all-unknown/empty/tsconfig.json
  • integration-tests/type-tests/customScalars/all-unknown/matchingTypes/index.ts
  • integration-tests/type-tests/customScalars/all-unknown/matchingTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/all-unknown/mixed/index.ts
  • integration-tests/type-tests/customScalars/all-unknown/mixed/tsconfig.json
  • integration-tests/type-tests/customScalars/differentTypes/index.ts
  • integration-tests/type-tests/customScalars/differentTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/empty/index.ts
  • integration-tests/type-tests/customScalars/empty/tsconfig.json
  • integration-tests/type-tests/customScalars/matchingTypes/index.ts
  • integration-tests/type-tests/customScalars/matchingTypes/tsconfig.json
  • integration-tests/type-tests/customScalars/mixed/index.ts
  • integration-tests/type-tests/customScalars/mixed/tsconfig.json
  • integration-tests/type-tests/tsconfig.json
  • package.json
  • src/cache/core/Scalar.ts
  • src/cache/core/__tests__/cache.ts
  • src/cache/core/cache.ts
  • src/cache/index.ts
  • src/cache/inmemory/__tests__/cache.diff/customScalars.test.ts
  • src/cache/inmemory/__tests__/cache.readFragment/customScalars.test.ts
  • src/cache/inmemory/__tests__/cache.readQuery/customScalars.test.ts
  • src/cache/inmemory/__tests__/cache.writeQuery/customScalars.test.ts
  • src/cache/inmemory/__tests__/scalars.ts
  • src/cache/inmemory/__tests__/variablesSerialization.ts
  • src/cache/inmemory/entityStore.ts
  • src/cache/inmemory/inMemoryCache.ts
  • src/cache/inmemory/policies.ts
  • src/cache/inmemory/readFromStore.ts
  • src/cache/inmemory/types.ts
  • src/core/QueryInfo.ts
  • src/core/QueryManager.ts
  • src/core/__tests__/client.mutate/customScalars.test.ts
  • src/core/__tests__/client.query/customScalars.test.ts
  • src/core/__tests__/client.readFragment/customScalars.test.ts
  • src/core/__tests__/client.readQuery/customScalars.test.ts
  • src/core/__tests__/client.refetchQueries/customScalars.test.ts
  • src/core/__tests__/client.subscribe/customScalars.test.ts
  • src/core/__tests__/client.watchFragment/customScalars.test.ts
  • src/core/__tests__/client.watchQuery/customScalars.test.ts
  • src/core/__tests__/client.writeQuery/customScalars.test.ts
  • src/core/index.ts
  • src/declarations.d.ts
  • src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx
  • src/react/hooks/__tests__/useFragment/customScalars.test.tsx
  • src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx
  • src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx
  • src/react/hooks/__tests__/useMutation/customScalars.test.tsx
  • src/react/hooks/__tests__/useQuery/customScalars.test.tsx
  • src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx
  • src/react/hooks/__tests__/useSubscription/customScalars.test.tsx
  • src/react/hooks/__tests__/useSuspenseFragment/customScalars.test.tsx
  • src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx
  • src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx
  • src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx
  • src/testing/internal/index.ts
  • src/testing/internal/scalars.ts
  • src/utilities/internal/getUnwrappedType.ts
  • src/utilities/internal/index.ts
  • src/utilities/internal/types/IsLooselyEqual.ts

Comment thread src/cache/core/Scalar.ts
Comment on lines +859 to +922
test.failing(
"parses custom scalar fields with a no-cache fetch policy",
async () => {
const query = gql`
query Event {
event {
id
startDate
}
}
`;
const client = new ApolloClient({
cache: new InMemoryCache({
scalars: { Date: dateScalar },
typePolicies: {
Event: {
fields: {
startDate: { scalar: "Date" },
},
},
},
}),
link: new ApolloLink(() =>
of({
data: {
event: {
__typename: "Event",
id: "1",
startDate: "2026-01-01",
},
},
}).pipe(delay(20))
),
});

using stream = new ObservableStream(
client.watchQuery({
query,
fetchPolicy: "no-cache",
})
);

await expect(stream).toEmitTypedValue({
data: undefined,
dataState: "empty",
loading: true,
networkStatus: NetworkStatus.loading,
partial: true,
});
await expect(stream).toEmitTypedValue({
data: {
event: {
__typename: "Event",
id: "1",
startDate: new Date(2026, 0, 1),
},
},
dataState: "complete",
loading: false,
networkStatus: NetworkStatus.ready,
partial: false,
});
}
);

@coderabbitai coderabbitai Bot Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

no-cache still bypasses scalar parsing.

Keeping this as test.failing means watchQuery({ fetchPolicy: "no-cache" }) still produces serialized scalar payloads instead of parsed values. That leaves one fetch policy outside the new custom-scalar runtime contract and will surface inconsistent result types to callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/__tests__/client.watchQuery/customScalars.test.ts` around lines 859
- 922, The no-cache watchQuery path is still bypassing custom scalar parsing, so
the result stays serialized instead of being converted through the scalar
runtime. Update the watchQuery/no-cache handling in ApolloClient and the related
result processing so fields configured via InMemoryCache typePolicies and
scalars are parsed consistently before emitting values, using the existing
custom scalar plumbing around watchQuery and ObservableStream. After the fix,
this customScalars test should no longer need test.failing and should pass with
a Date value for startDate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, this is intentional and will be addressed in a future PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jerelmiller Got it, thanks for confirming. Sounds good — I'll leave this as-is for now, given it's tracked for a future PR.

(=^・ω・^=)


🧠 Learnings used
Learnt from: phryneas
Repo: apollographql/apollo-client PR: 13132
File: src/core/defaultOptions.ts:5-7
Timestamp: 2026-03-09T13:28:57.460Z
Learning: In the apollographql/apollo-client repository, do not flag lint issues related to Biome for TypeScript files. There is no biome.json/biome.jsonc and no Biome dependency in package.json. Do not suggest biome-ignore comments or Biome-specific lint fixes for any TS file.

Comment on lines +391 to +447
test.failing(
"parses custom scalar fields with a no-cache fetch policy",
async () => {
const mutation = gql`
mutation CreateEvent {
createEvent {
id
startDate
}
}
`;
const client = new ApolloClient({
cache: new InMemoryCache({
scalars: { Date: dateScalar },
typePolicies: {
Event: {
fields: {
startDate: { scalar: "Date" },
},
},
},
}),
link: new ApolloLink(() =>
of({
data: {
createEvent: {
__typename: "Event",
id: "1",
startDate: "2026-01-01",
},
},
}).pipe(delay(20))
),
});

using _disabledAct = disableActEnvironment();
const { takeSnapshot, getCurrentSnapshot } =
await renderHookToSnapshotStream(() => useMutation(mutation), {
wrapper: createClientWrapper(client),
});

await takeSnapshot();
const [mutate] = getCurrentSnapshot();

await expect(
mutate({ fetchPolicy: "no-cache" })
).resolves.toStrictEqualTyped({
data: {
createEvent: {
__typename: "Event",
id: "1",
startDate: new Date(2026, 0, 1),
},
},
});
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

mutate({ fetchPolicy: "no-cache" }) still skips scalar coercion.

This failing test shows mutation results returned without caching still bypass custom-scalar parsing, so the no-cache path exposes a different runtime shape than the cached/network-only paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/react/hooks/__tests__/useMutation/customScalars.test.tsx` around lines
391 - 447, The no-cache path in useMutation still bypasses custom scalar
coercion, so mutation results differ from the cached/network-only flows. Update
the mutation result handling in useMutation (and any helper it uses for result
normalization) so scalar parsing still runs when fetchPolicy is "no-cache",
using the same scalar/type policy logic exercised by the custom scalar tests and
the Date field mapping on Event.startDate. Ensure the mutate() result shape
stays consistent with the existing parsed behavior.

Comment on lines +633 to +698
test.failing(
"parses custom scalar fields with a no-cache fetch policy",
async () => {
const query = gql`
query Event {
event {
id
startDate
}
}
`;
const client = new ApolloClient({
cache: new InMemoryCache({
scalars: { Date: dateScalar },
typePolicies: {
Event: {
fields: {
startDate: { scalar: "Date" },
},
},
},
}),
link: new ApolloLink(() =>
of({
data: {
event: {
__typename: "Event",
id: "1",
startDate: "2026-01-01",
},
},
}).pipe(delay(20))
),
});

using _disabledAct = disableActEnvironment();
const { takeSnapshot } = await renderHookToSnapshotStream(
() => useQuery(query, { fetchPolicy: "no-cache" }),
{ wrapper: createClientWrapper(client) }
);

await expect(takeSnapshot()).resolves.toStrictEqualTyped({
data: undefined,
dataState: "empty",
loading: true,
networkStatus: NetworkStatus.loading,
previousData: undefined,
variables: {},
});

await expect(takeSnapshot()).resolves.toStrictEqualTyped({
data: {
event: {
__typename: "Event",
id: "1",
startDate: new Date(2026, 0, 1),
},
},
dataState: "complete",
loading: false,
networkStatus: NetworkStatus.ready,
previousData: undefined,
variables: {},
});
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

no-cache still bypasses custom scalar parsing.

This test codifies an unresolved behavior gap: useQuery(..., { fetchPolicy: "no-cache" }) still returns wire-format scalar values while the other fetch policies return parsed runtime values. That policy-dependent type drift undercuts the PR’s “first-class custom scalar support” contract and will leak straight to callers using Date-like scalars. Please fix the no-cache result path in the underlying QueryManager/QueryInfo pipeline instead of merging with an expected failure here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/react/hooks/__tests__/useQuery/customScalars.test.tsx` around lines 633 -
698, The no-cache query result path is still skipping custom scalar parsing, so
`useQuery` returns wire-format values instead of runtime scalar values. Update
the underlying `QueryManager`/`QueryInfo` no-cache flow so it applies the same
scalar coercion used by other fetch policies, and verify the fix against
`useQuery`, `ApolloClient`, and the `Date` scalar setup in
`customScalars.test.tsx`. Avoid keeping this as `test.failing`; the expected
behavior should be that `fetchPolicy: "no-cache"` returns parsed scalar values
too.

@jerelmiller jerelmiller merged commit 81d546a into release-4.3 Jun 30, 2026
61 checks passed
@jerelmiller jerelmiller mentioned this pull request Jun 30, 2026
jerelmiller added a commit that referenced this pull request Jun 30, 2026
Small improvements mentioned in
#13270 (review).

- Keeps `cache.extract()` cheap when no scalars are configured
- Reuses an existing variable to remove unneeded duplicate code

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved cache extraction so scalar values are only normalized when
scalar handling is enabled.
  * Preserved stored values unchanged when scalar handling is disabled.
* Refined store field checks to avoid redundant lookups during
validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@jerelmiller jerelmiller deleted the feat/custom-scalars branch June 30, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant