From d6a677f414a87cfe8ef0fc532a3a01025449caf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 24 Sep 2026 15:30:01 +0200 Subject: [PATCH] Type useQuery data as partial when returnPartialData is true With returnPartialData, data can miss fields, so Apollo Client types it as DataValue.Partial; useQuery typed it as complete. QueryResource gets a third type parameter for the shape of data, and the returnPartialData overloads (literal true or a boolean flag, as in Apollo's own overloads) return it as DataValue.Partial>. onComplete receives the same shape. A complete resource stays assignable to a partial one. Also drop the NoInfer import from @apollo/client/utilities/internal: Apollo 4.3 removed it and TypeScript 5.4+ ships it. This is what failed the floating dependencies CI job. --- docs/fetching/queries.md | 10 +++ glimmer-apollo/src/-private/query.ts | 42 +++++++++--- glimmer-apollo/src/-private/usables.ts | 43 ++++++++++-- glimmer-apollo/src/index.ts | 1 + test-app/tests/unit/query-test.ts | 36 ++++++++++ test-app/tests/unit/types/query-types-test.ts | 67 ++++++++++++++++++- 6 files changed, 183 insertions(+), 16 deletions(-) diff --git a/docs/fetching/queries.md b/docs/fetching/queries.md index 4a98569..a9077fa 100644 --- a/docs/fetching/queries.md +++ b/docs/fetching/queries.md @@ -232,6 +232,16 @@ setClient( notes = useQuery(this, () => [GET_NOTES, { clientId: 'my-custom-client' }]); ``` +### `returnPartialData` + +With `returnPartialData: true`, Apollo Client reads whatever the cache already holds for the query while the network request is in flight, so `data` can miss fields. `useQuery` then returns a `PartialQueryResource`: `data` and the `onComplete` argument are typed as `DataValue.Partial` from `@apollo/client`. + +```ts +notes = useQuery(this, () => [GET_NOTES, { returnPartialData: true }]); + +// notes.data?.notes may be undefined, and each note may miss fields +``` + ## Query Status ### `loading` diff --git a/glimmer-apollo/src/-private/query.ts b/glimmer-apollo/src/-private/query.ts index 85b6e52..5a1f4a5 100644 --- a/glimmer-apollo/src/-private/query.ts +++ b/glimmer-apollo/src/-private/query.ts @@ -14,6 +14,7 @@ import { createPromise, getFastboot, settled } from './utils.ts'; import type { ApolloClient, + DataValue, DocumentNode, ErrorLike, MaybeMasked, @@ -24,7 +25,15 @@ import type { import type { Subscription } from 'rxjs'; import type { TemplateArgs } from './types'; -export type QueryOptions = Omit< +/** + * `TResultData` is the shape `data` (and `onComplete`'s argument) is typed + * as: complete by default, `DataValue.Partial` when `returnPartialData` is set. + */ +export type QueryOptions< + TData, + TVariables extends OperationVariables, + TResultData = MaybeMasked, +> = Omit< ApolloClient.WatchQueryOptions, 'query' | 'variables' > & { @@ -32,21 +41,33 @@ export type QueryOptions = Omit< skip?: boolean; ssr?: boolean; clientId?: string; - onComplete?: (data: MaybeMasked | undefined) => void; + onComplete?: (data: TResultData | undefined) => void; onError?: (error: ErrorLike) => void; }; export type QueryPositionalArgs< TData, TVariables extends OperationVariables = OperationVariables, + TResultData = MaybeMasked, > = [ DocumentNode | TypedDocumentNode, - QueryOptions?, + QueryOptions?, ]; +/** + * A query read with `returnPartialData`: `data` can miss fields, whether it is + * a cache read before the network answers, a result with `errorPolicy: 'all'` + * where a field errored, or a cache read after an eviction. + */ +export type PartialQueryResource< + TData, + TVariables extends OperationVariables = OperationVariables, +> = QueryResource>>; + export class QueryResource< TData, TVariables extends OperationVariables = OperationVariables, + TResultData = MaybeMasked, > extends ObservableResource< TData, TVariables, @@ -54,7 +75,7 @@ export class QueryResource< > { @tracked loading = false; @tracked error?: ErrorLike; - @tracked data: MaybeMasked | undefined; + @tracked data: TResultData | undefined; @tracked networkStatus: NetworkStatus = NetworkStatus.loading; @tracked promise!: Promise; @@ -171,11 +192,9 @@ export class QueryResource< const { loading, error, data, networkStatus } = result; this.loading = loading; - // Cast: Apollo Client 4's result type includes DeepPartial to - // account for returnPartialData. We expose the stricter TData since - // consumers who opt into returnPartialData already expect partial shapes. - // If AC4 tightens this typing in a future version, revisit this cast. - this.data = data as MaybeMasked | undefined; + // Apollo types every result's data as complete | partial; the overload + // that built this resource decided which of the two TResultData is. + this.data = data as TResultData | undefined; this.networkStatus = networkStatus; this.error = error; @@ -202,7 +221,10 @@ export class QueryResource< const invoke = (): void => { if (onComplete && !error) { - onComplete(data); + // args keep the default options type so a complete resource stays + // assignable to a partial one; the overload matched onComplete's + // parameter to TResultData already. + onComplete(data as MaybeMasked | undefined); } else if (onError && error) { onError(error); } diff --git a/glimmer-apollo/src/-private/usables.ts b/glimmer-apollo/src/-private/usables.ts index 6e8f388..bad43ca 100644 --- a/glimmer-apollo/src/-private/usables.ts +++ b/glimmer-apollo/src/-private/usables.ts @@ -5,6 +5,7 @@ import { MutationResource, } from './mutation.ts'; import { + type PartialQueryResource, type QueryOptions, type QueryPositionalArgs, QueryResource, @@ -13,11 +14,25 @@ import { type SubscriptionPositionalArgs, SubscriptionResource, } from './subscription.ts'; -import type { OperationVariables, TypedDocumentNode } from '@apollo/client'; import type { - NoInfer, - SignatureStyle, -} from '@apollo/client/utilities/internal'; + DataValue, + MaybeMasked, + OperationVariables, + TypedDocumentNode, +} from '@apollo/client'; +import type { SignatureStyle } from '@apollo/client/utilities/internal'; + +/** + * Options that select the partial overload. `returnPartialData: boolean` + * rather than `true` so a flag variable also lands here, as in Apollo's own + * `useQuery` overloads. + */ +type PartialQueryOptions< + TData, + TVariables extends OperationVariables, +> = QueryOptions>> & { + returnPartialData: boolean; +}; /* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-empty-object-type -- Namespaces and the empty-extends interface mirror Apollo Client 4.2's own @@ -28,6 +43,16 @@ import type { export namespace useQuery { export namespace Signatures { export interface Classic { + < + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + parentDestroyable: object, + args: () => [ + QueryPositionalArgs[0], + PartialQueryOptions, + ], + ): PartialQueryResource; < TData = unknown, TVariables extends OperationVariables = OperationVariables, @@ -37,6 +62,16 @@ export namespace useQuery { ): QueryResource; } export interface Modern { + < + TData = unknown, + TVariables extends OperationVariables = OperationVariables, + >( + parentDestroyable: object, + args: () => [ + TypedDocumentNode, + PartialQueryOptions>, + ], + ): PartialQueryResource; < TData = unknown, TVariables extends OperationVariables = OperationVariables, diff --git a/glimmer-apollo/src/index.ts b/glimmer-apollo/src/index.ts index c41a4e4..0307fe3 100644 --- a/glimmer-apollo/src/index.ts +++ b/glimmer-apollo/src/index.ts @@ -12,6 +12,7 @@ export type { UseSubscription, } from './-private/usables.ts'; export type { + PartialQueryResource, QueryOptions, QueryResource, QueryPositionalArgs, diff --git a/test-app/tests/unit/query-test.ts b/test-app/tests/unit/query-test.ts index bc6edc9..0dec0b4 100644 --- a/test-app/tests/unit/query-test.ts +++ b/test-app/tests/unit/query-test.ts @@ -102,6 +102,42 @@ module('useQuery', function (hooks) { assert.equal(query.data?.user?.id, '2'); }); + test('it reports partial data from the cache, then complete', async function (assert) { + const partialClient = new ApolloClient({ + cache: new InMemoryCache(), + link, + }); + setClient(ctx, partialClient); + partialClient.writeQuery({ + query: gql` + query UserFirstName($id: ID!) { + user(id: $id) { + id + firstName + } + } + `, + variables: { id: '1' }, + data: { user: { __typename: 'User', id: '1', firstName: 'Cathaline' } }, + }); + + const lastNames: (string | undefined)[] = []; + const query = useQuery(ctx, () => [ + USER_INFO, + { + variables: { id: '1' }, + returnPartialData: true, + onComplete: (data) => lastNames.push(data?.user?.lastName), + }, + ]); + + assert.equal(query.data?.user?.firstName, 'Cathaline'); + assert.equal(query.data?.user?.lastName, undefined); + await query.promise; + assert.equal(query.data?.user?.lastName, 'McCoy'); + assert.deepEqual(lastNames, [undefined, 'McCoy']); + }); + test('it returns error', async function (assert) { const query = useQuery(ctx, () => [ USER_INFO, diff --git a/test-app/tests/unit/types/query-types-test.ts b/test-app/tests/unit/types/query-types-test.ts index 5c51ad7..33757a7 100644 --- a/test-app/tests/unit/types/query-types-test.ts +++ b/test-app/tests/unit/types/query-types-test.ts @@ -1,7 +1,7 @@ import { module, test } from 'qunit'; import { useQuery } from 'glimmer-apollo'; -import type { QueryResource } from 'glimmer-apollo'; -import type { TypedDocumentNode } from '@apollo/client'; +import type { PartialQueryResource, QueryResource } from 'glimmer-apollo'; +import type { DataValue, TypedDocumentNode } from '@apollo/client'; import type { UserInfoQuery, UserInfoQueryVariables, @@ -42,6 +42,69 @@ function _typeAssertions() { expectTypeOf(qc).toEqualTypeOf< QueryResource >(); + + // returnPartialData: data may miss fields. + const qp = useQueryModern(ctx, () => [ + USER_INFO, + { variables: { id: '1' }, returnPartialData: true }, + ]); + expectTypeOf(qp).toEqualTypeOf< + PartialQueryResource + >(); + expectTypeOf(qp.data).toEqualTypeOf< + DataValue.Partial | undefined + >(); + if (qp.data?.user) { + // @ts-expect-error - a field of partial data may be missing + takesString(qp.data.user.firstName); + } + if (q.data?.user) { + takesString(q.data.user.firstName); + } + + const qcp = useQueryClassic( + ctx, + () => [USER_INFO, { variables: { id: '1' }, returnPartialData: true }] + ); + expectTypeOf(qcp.data).toEqualTypeOf< + DataValue.Partial | undefined + >(); + + // A boolean flag, not only the literal true, selects the partial shape. + const flag = Boolean(ctx); + const qf = useQueryModern(ctx, () => [ + USER_INFO, + { variables: { id: '1' }, returnPartialData: flag }, + ]); + expectTypeOf(qf.data).toEqualTypeOf< + DataValue.Partial | undefined + >(); + + // onComplete receives the same partial shape as data. + useQueryModern(ctx, () => [ + USER_INFO, + { + variables: { id: '1' }, + returnPartialData: true, + onComplete: (data) => { + expectTypeOf(data).toEqualTypeOf< + DataValue.Partial | undefined + >(); + }, + }, + ]); + + // A complete resource fits where a partial one is expected, not the reverse. + expectTypeOf(q).toExtend< + PartialQueryResource + >(); + expectTypeOf(qp).not.toExtend< + QueryResource + >(); +} + +function takesString(value: string) { + return value; } // Default (no TypeOverrides augmentation): the exported `useQuery` resolves to