diff --git a/config/jest.config.ts b/config/jest.config.ts index 5d2a19d8090..e7bf53b2fa6 100644 --- a/config/jest.config.ts +++ b/config/jest.config.ts @@ -42,6 +42,7 @@ const reactSharedTestFileIgnoreList = [ ignoreDTSFiles, ignoreTSFiles, "src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx", + "src/react/hooks/__tests__/useLoadableQuery/testUtils.tsx", "src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx", "src/react/query-preloader/__tests__/createQueryPreloader/testUtils.tsx", ]; diff --git a/src/core/__tests__/client.mutate/customScalars.test.ts b/src/core/__tests__/client.mutate/customScalars.test.ts index c2444645784..8f9210fc56b 100644 --- a/src/core/__tests__/client.mutate/customScalars.test.ts +++ b/src/core/__tests__/client.mutate/customScalars.test.ts @@ -1,7 +1,13 @@ import { delay, of } from "rxjs"; import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { MockLink } from "@apollo/client/testing"; import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; @@ -658,3 +664,196 @@ test("parses custom scalar fields in queries triggered by refetchQueries", async }, }); }); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ mutation, errorPolicy: "none" }) + ).rejects.toEqual( + new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }) + ); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ mutation, errorPolicy: "all" }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }), + }); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect( + client.mutate({ mutation, errorPolicy: "ignore" }) + ).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + }); +}); diff --git a/src/core/__tests__/client.query/customScalars.test.ts b/src/core/__tests__/client.query/customScalars.test.ts index b4071d63b4b..07141c18341 100644 --- a/src/core/__tests__/client.query/customScalars.test.ts +++ b/src/core/__tests__/client.query/customScalars.test.ts @@ -1,7 +1,12 @@ import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { dateScalar } from "@apollo/client/testing/internal"; @@ -443,3 +448,248 @@ test.failing( }); } ); + +test("preserves referential identity when fetching identical serialized scalar values", 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)) + ), + }); + + const { data: first } = await client.query({ + query, + fetchPolicy: "network-only", + }); + + expect(first).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); + + const { data: second } = await client.query({ + query, + fetchPolicy: "network-only", + }); + + expect(second).toBe(first); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect(client.query({ query, errorPolicy: "none" })).rejects.toEqual( + new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }) + ); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect( + client.query({ query, errorPolicy: "all" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + await expect( + client.query({ query, errorPolicy: "ignore" }) + ).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + }); +}); diff --git a/src/core/__tests__/client.readQuery/customScalars.test.ts b/src/core/__tests__/client.readQuery/customScalars.test.ts index 16890c39044..b0898f18d9c 100644 --- a/src/core/__tests__/client.readQuery/customScalars.test.ts +++ b/src/core/__tests__/client.readQuery/customScalars.test.ts @@ -162,3 +162,61 @@ test("returns parsed custom scalar fields", () => { }, }); }); + +test("preserves referential identity when writing identical serialized scalar values", () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: ApolloLink.empty(), + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + const previousResult = client.readQuery({ query }); + + expect(previousResult).toStrictEqualTyped({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }); + + client.writeQuery({ + query, + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }); + + expect(client.readQuery({ query })).toBe(previousResult); +}); diff --git a/src/core/__tests__/client.subscribe/customScalars.test.ts b/src/core/__tests__/client.subscribe/customScalars.test.ts index 0e77a52a7a6..be12a659adf 100644 --- a/src/core/__tests__/client.subscribe/customScalars.test.ts +++ b/src/core/__tests__/client.subscribe/customScalars.test.ts @@ -1,7 +1,12 @@ import { delay, of } from "rxjs"; -import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql } from "@apollo/client"; +import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; @@ -401,3 +406,278 @@ test.failing( await expect(stream).toComplete(); } ); + +test("preserves referential identity when a subscription emits identical serialized scalar values", async () => { + const subscription: TypedDocumentNode<{ + eventCreated: { __typename: "Event"; id: string; startDate: Date }; + }> = gql` + subscription EventCreated { + eventCreated { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of( + { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }, + { + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + } + ).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ query: subscription }) + ); + + const first = await stream.takeNext(); + + expect(first).toStrictEqualTyped({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + const second = await stream.takeNext(); + + expect(second.data!.eventCreated).toBe(first.data!.eventCreated); + + await expect(stream).toComplete(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["eventCreated", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ query: subscription, errorPolicy: "none" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["eventCreated", "endDate"], + }, + ], + }), + }); + + await expect(stream).toComplete(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["eventCreated", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ query: subscription, errorPolicy: "all" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["eventCreated", "endDate"], + }, + ], + }), + }); + + await expect(stream).toComplete(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const subscription = gql` + subscription EventCreated { + eventCreated { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["eventCreated", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.subscribe({ query: subscription, errorPolicy: "ignore" }) + ); + + await expect(stream).toEmitTypedValue({ + data: { + eventCreated: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + }); + + await expect(stream).toComplete(); +}); diff --git a/src/core/__tests__/client.watchQuery/customScalars.test.ts b/src/core/__tests__/client.watchQuery/customScalars.test.ts index c5a3c3cfafa..73eb0831416 100644 --- a/src/core/__tests__/client.watchQuery/customScalars.test.ts +++ b/src/core/__tests__/client.watchQuery/customScalars.test.ts @@ -1,9 +1,25 @@ import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import { dateScalar, ObservableStream } from "@apollo/client/testing/internal"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; +import { + dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, + ObservableStream, +} from "@apollo/client/testing/internal"; test("serializes scalar variables used in field arguments", async () => { let requestVariables!: OperationVariables; @@ -920,3 +936,1043 @@ test.failing( }); } ); + +test("preserves referential identity when refetching identical serialized scalar values", 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)) + ), + }); + + const observable = client.watchQuery({ + query, + notifyOnNetworkStatusChange: false, + }); + using stream = new ObservableStream(observable); + + 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, + }); + + const { data: previousData } = observable.getCurrentResult(); + + await expect(observable.refetch()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + 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, + }); + + expect(observable.getCurrentResult().data).toBe(previousData); + + await expect(stream).not.toEmitAnything(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "none" }) + ); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + partial: true, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "all" }) + ); + + 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), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.error, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using stream = new ObservableStream( + client.watchQuery({ query, errorPolicy: "ignore" }) + ); + + 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), + endDate: null, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@defer` payloads when refetching (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + const observable = client.watchQuery({ query }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + const refetchPromise = observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2027-01-01", + }, + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2027-02-02" }, path: ["event"] }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2027, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2027, 1, 2), + }, + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@defer` payloads when refetching (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + const observable = client.watchQuery({ query }); + using stream = new ObservableStream(observable); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: true, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + const refetchPromise = observable.refetch(); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + partial: false, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2027-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2027-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2027, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2027, 0, 1), + endDate: new Date(2027, 1, 2), + }, + }, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using stream = new ObservableStream(client.watchQuery({ query })); + + await expect(stream).toEmitTypedValue({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + partial: true, + }); + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + await expect(stream).toEmitTypedValue({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + partial: false, + }); + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(stream).toEmitTypedValue({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + partial: false, + }); + + await expect(stream).not.toEmitAnything(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx index d5f024bfdae..9d6bcf7d21e 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery/customScalars.test.tsx @@ -1,13 +1,29 @@ +import assert from "node:assert"; + import { disableActEnvironment } from "@testing-library/react-render-stream"; -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { useBackgroundQuery } from "@apollo/client/react"; import { createClientWrapper, dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, } from "@apollo/client/testing/internal"; import { renderUseBackgroundQuery } from "./testUtils.js"; @@ -43,9 +59,14 @@ test("serializes scalar variables used in field arguments", async () => { { wrapper: createClientWrapper(client) } ); - await expect(takeRender()).resolves.toMatchObject({ - renderedComponents: ["useBackgroundQuery", ""], - }); + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } { const { snapshot, renderedComponents } = await takeRender(); @@ -99,9 +120,14 @@ test("serializes scalar variables used in directive arguments", async () => { { wrapper: createClientWrapper(client) } ); - await expect(takeRender()).resolves.toMatchObject({ - renderedComponents: ["useBackgroundQuery", ""], - }); + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } { const { snapshot, renderedComponents } = await takeRender(); @@ -164,9 +190,14 @@ test("serializes scalar fields in input object variables", async () => { { wrapper: createClientWrapper(client) } ); - await expect(takeRender()).resolves.toMatchObject({ - renderedComponents: ["useBackgroundQuery", ""], - }); + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } { const { snapshot, renderedComponents } = await takeRender(); @@ -190,3 +221,816 @@ test("serializes scalar fields in input object variables", async () => { filter: { date: "2026-01-01" }, }); }); + +test("preserves referential identity when refetching identical serialized scalar values", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, refetch } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + let previousData: unknown; + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + assert("data" in snapshot); + + previousData = snapshot.data; + } + + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + assert("data" in snapshot); + + expect(snapshot.data).toBe(previousData); + } + + await expect(takeRender).not.toRerender(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + using _consoleSpy = spyOnConsole("error"); + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query, { errorPolicy: "none" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseBackgroundQuery( + () => useBackgroundQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useBackgroundQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx b/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx index e1d34750a5b..cdeec1ac208 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery/testUtils.tsx @@ -4,6 +4,7 @@ import { useTrackRenders, } from "@testing-library/react-render-stream"; import React, { Suspense } from "react"; +import { flushSync } from "react-dom"; import { ErrorBoundary } from "react-error-boundary"; import type { DataState, ErrorLike, OperationVariables } from "@apollo/client"; @@ -45,9 +46,13 @@ export async function renderUseBackgroundQuery< return null; } + let currentRefetch!: useBackgroundQuery.Result["refetch"]; + function App({ props }: { props: Props | undefined }) { useTrackRenders({ name: "useBackgroundQuery" }); - const [queryRef] = renderHook(props as any); + const [queryRef, { refetch }] = renderHook(props as any); + + currentRefetch = refetch; return ( }> @@ -71,5 +76,13 @@ export async function renderUseBackgroundQuery< return utils.rerender(); } - return { takeRender, rerender }; + // React 18 skips committing the suspense fallback when refetch is triggered + // as a default-priority update, so the fallback render is missed and tests + // may fail. flushSync forces a synchronous commit so the fallback renders in + // both React 18 and 19, while returning the refetch promise. + const refetch: typeof currentRefetch = (...args) => { + return flushSync(() => currentRefetch(...args)); + }; + + return { takeRender, rerender, refetch }; } diff --git a/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx index 10118829de4..2a30cfdf851 100644 --- a/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useLazyQuery/customScalars.test.tsx @@ -5,7 +5,13 @@ import { import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; import { useLazyQuery } from "@apollo/client/react"; import { @@ -1056,3 +1062,550 @@ test.failing( } } ); + +test("preserves referential identity when re-executing with identical serialized scalar values", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { fetchPolicy: "network-only" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + let previousData: unknown; + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + previousData = result.data; + } + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + expect(result.data).toBe(previousData); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { errorPolicy: "none" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).rejects.toEqual( + new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + called: true, + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + called: true, + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useLazyQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: false, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + const [execute] = getCurrentSnapshot(); + + await expect(execute()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + dataState: "empty", + called: true, + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + called: true, + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx index 9271b6e631f..f06d1a9df92 100644 --- a/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery/customScalars.test.tsx @@ -1,22 +1,31 @@ -import { screen } from "@testing-library/react"; -import { - createRenderStream, - disableActEnvironment, - useTrackRenders, -} from "@testing-library/react-render-stream"; -import { userEvent } from "@testing-library/user-event"; -import React, { Suspense } from "react"; +import { disableActEnvironment } from "@testing-library/react-render-stream"; import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; -import type { QueryRef } from "@apollo/client/react"; -import { useLoadableQuery, useReadQuery } from "@apollo/client/react"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; +import { useLoadableQuery } from "@apollo/client/react"; import { createClientWrapper, dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, } from "@apollo/client/testing/internal"; +import { invariant } from "@apollo/client/utilities/invariant"; + +import { renderUseLoadableQueryHook } from "./testUtils.js"; test("serializes scalar variables used in field arguments", async () => { let requestVariables!: OperationVariables; @@ -34,13 +43,6 @@ test("serializes scalar variables used in field arguments", async () => { }), }); - const renderStream = createRenderStream({ - initialSnapshot: { - result: null as useReadQuery.Result | null, - }, - skipNonTrackingRenders: true, - }); - const query = gql` query Event($date: Date!) { event(date: $date) { @@ -49,53 +51,33 @@ test("serializes scalar variables used in field arguments", async () => { } `; - function ReadQuery({ queryRef }: { queryRef: QueryRef }) { - useTrackRenders({ name: "useReadQuery" }); - renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); - - return null; - } - - function Fallback() { - useTrackRenders({ name: "" }); - return null; - } + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); - function App() { - useTrackRenders({ name: "useLoadableQuery" }); - const [loadQuery, queryRef] = useLoadableQuery(query); + { + const { renderedComponents } = await takeRender(); - return ( - <> - - }> - {queryRef && } - - - ); + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); } - using _disabledAct = disableActEnvironment(); - const user = userEvent.setup(); - await renderStream.render(, { - wrapper: createClientWrapper(client), - }); - - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery"], - }); + loadQuery({ date: new Date(2026, 0, 1) }); - await user.click(screen.getByText("Load query")); + { + const { renderedComponents } = await takeRender(); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery", ""], - }); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } { - const { snapshot, renderedComponents } = await renderStream.takeRender(); + const { snapshot, renderedComponents } = await takeRender(); + invariant("result" in snapshot); expect(renderedComponents).toStrictEqual(["useReadQuery"]); expect(snapshot.result).toStrictEqualTyped({ data: { @@ -110,7 +92,7 @@ test("serializes scalar variables used in field arguments", async () => { }); } - await expect(renderStream).not.toRerender(); + await expect(takeRender).not.toRerender(); expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); }); @@ -130,13 +112,6 @@ test("serializes scalar variables used in directive arguments", async () => { }), }); - const renderStream = createRenderStream({ - initialSnapshot: { - result: null as useReadQuery.Result | null, - }, - skipNonTrackingRenders: true, - }); - const query = gql` query Event($date: Date!) { event @on(date: $date) { @@ -145,53 +120,33 @@ test("serializes scalar variables used in directive arguments", async () => { } `; - function ReadQuery({ queryRef }: { queryRef: QueryRef }) { - useTrackRenders({ name: "useReadQuery" }); - renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); - - return null; - } - - function Fallback() { - useTrackRenders({ name: "" }); - return null; - } + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); - function App() { - useTrackRenders({ name: "useLoadableQuery" }); - const [loadQuery, queryRef] = useLoadableQuery(query); + { + const { renderedComponents } = await takeRender(); - return ( - <> - - }> - {queryRef && } - - - ); + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); } - using _disabledAct = disableActEnvironment(); - const user = userEvent.setup(); - await renderStream.render(, { - wrapper: createClientWrapper(client), - }); - - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery"], - }); + loadQuery({ date: new Date(2026, 0, 1) }); - await user.click(screen.getByText("Load query")); + { + const { renderedComponents } = await takeRender(); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery", ""], - }); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } { - const { snapshot, renderedComponents } = await renderStream.takeRender(); + const { snapshot, renderedComponents } = await takeRender(); + invariant("result" in snapshot); expect(renderedComponents).toStrictEqual(["useReadQuery"]); expect(snapshot.result).toStrictEqualTyped({ data: { @@ -206,7 +161,7 @@ test("serializes scalar variables used in directive arguments", async () => { }); } - await expect(renderStream).not.toRerender(); + await expect(takeRender).not.toRerender(); expect(requestVariables).toStrictEqualTyped({ date: "2026-01-01" }); }); @@ -233,13 +188,6 @@ test("serializes scalar fields in input object variables", async () => { }), }); - const renderStream = createRenderStream({ - initialSnapshot: { - result: null as useReadQuery.Result | null, - }, - skipNonTrackingRenders: true, - }); - const query = gql` query Event($filter: EventFilter!) { event(filter: $filter) { @@ -248,61 +196,448 @@ test("serializes scalar fields in input object variables", async () => { } `; - function ReadQuery({ queryRef }: { queryRef: QueryRef }) { - useTrackRenders({ name: "useReadQuery" }); - renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); - return null; + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); } - function Fallback() { - useTrackRenders({ name: "" }); - return null; + loadQuery({ filter: { date: new Date(2026, 0, 1) } }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); } - function App() { - useTrackRenders({ name: "useLoadableQuery" }); - const [loadQuery, queryRef] = useLoadableQuery(query); + { + const { snapshot, renderedComponents } = await takeRender(); - return ( - <> - - }> - {queryRef && } - - - ); + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + name: "GraphQL Summit", + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); } + await expect(takeRender).not.toRerender(); + expect(requestVariables).toStrictEqualTyped({ + filter: { date: "2026-01-01" }, + }); +}); + +test("preserves referential identity when refetching identical serialized scalar values", 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 user = userEvent.setup(); - await renderStream.render(, { - wrapper: createClientWrapper(client), + const { takeRender, refetch, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + let previousData: unknown; + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + previousData = snapshot.result!.data; + } + + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + expect(snapshot.result!.data).toBe(previousData); + } + + await expect(takeRender).not.toRerender(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + using _consoleSpy = spyOnConsole("error"); + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery"], + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query, { errorPolicy: "none" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), }); - await user.click(screen.getByText("Load query")); + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useLoadableQuery", ""], +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), }); + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + { - const { snapshot, renderedComponents } = await renderStream.takeRender(); + const { renderedComponents } = await takeRender(); + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); expect(renderedComponents).toStrictEqual(["useReadQuery"]); expect(snapshot.result).toStrictEqualTyped({ data: { event: { __typename: "Event", - name: "GraphQL Summit", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, }, }, dataState: "complete", @@ -311,8 +646,483 @@ test("serializes scalar fields in input object variables", async () => { }); } - await expect(renderStream).not.toRerender(); - expect(requestVariables).toStrictEqualTyped({ - filter: { date: "2026-01-01" }, + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender, loadQuery } = await renderUseLoadableQueryHook( + () => useLoadableQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useLoadableQuery"]); + } + + loadQuery(); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useLoadableQuery", + "", + ]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + invariant("result" in snapshot); + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); }); diff --git a/src/react/hooks/__tests__/useLoadableQuery/testUtils.tsx b/src/react/hooks/__tests__/useLoadableQuery/testUtils.tsx new file mode 100644 index 00000000000..1bbc6536b94 --- /dev/null +++ b/src/react/hooks/__tests__/useLoadableQuery/testUtils.tsx @@ -0,0 +1,125 @@ +import type { RenderOptions } from "@testing-library/react"; +import { + createRenderStream, + useTrackRenders, +} from "@testing-library/react-render-stream"; +import React, { Suspense } from "react"; +import { flushSync } from "react-dom"; +import { ErrorBoundary } from "react-error-boundary"; + +import type { DataState, ErrorLike, OperationVariables } from "@apollo/client"; +import { + type QueryRef, + type useLoadableQuery, + useReadQuery, +} from "@apollo/client/react"; +import { invariant } from "@apollo/client/utilities/invariant"; + +export async function renderUseLoadableQueryHook< + TData, + TVariables extends OperationVariables, + TStates extends DataState["dataState"] = DataState["dataState"], + Props = never, +>( + renderHook: ( + props: Props extends never ? undefined : Props + ) => useLoadableQuery.Result, + options: Pick & { initialProps?: Props } +) { + function UseReadQuery({ + queryRef, + }: { + queryRef: QueryRef; + }) { + useTrackRenders({ name: "useReadQuery" }); + mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function SuspenseFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + function ErrorFallback() { + useTrackRenders({ name: "" }); + + return null; + } + + type RefetchFunction = useLoadableQuery.Handlers< + TData, + TVariables + >["refetch"]; + + function App({ props }: { props: Props | undefined }) { + useTrackRenders({ name: "useLoadableQuery" }); + const [loadQuery, queryRef, { refetch }] = renderHook(props as any); + + mergeSnapshot({ loadQuery, refetch }); + + return ( + }> + replaceSnapshot({ error })} + > + {queryRef && } + + + ); + } + + const { + render, + getCurrentRender, + takeRender, + mergeSnapshot, + replaceSnapshot, + } = createRenderStream< + | { + loadQuery: useLoadableQuery.LoadQueryFunction; + refetch: RefetchFunction; + result?: useReadQuery.Result; + } + | { error: ErrorLike } + >({ + // These values should always be available, but createRenderStream needs an + // initial snapshot when using mergeSnapshot so we provide it with something + initialSnapshot: { loadQuery: null as any, refetch: null as any }, + }); + + const utils = await render(, options); + + function rerender(props: Props) { + return utils.rerender(); + } + + function getCurrentSnapshot() { + const { snapshot } = getCurrentRender(); + invariant( + "loadQuery" in snapshot, + "Expected rendered hook instead of error boundary" + ); + + return snapshot; + } + + // React 18 skips committing the suspense fallback when loadQuery/refetch is + // triggered as a default-priority update, so the fallback render is missed + // and tests may fail. flushSync forces a synchronous commit so the fallback + // renders in both React 18 and 19, while returning the underlying result. + const loadQuery: useLoadableQuery.LoadQueryFunction = ( + ...args + ) => { + return flushSync(() => getCurrentSnapshot().loadQuery(...args)); + }; + + const refetch: RefetchFunction = (...args) => { + return flushSync(() => getCurrentSnapshot().refetch(...args)); + }; + + return { takeRender, rerender, getCurrentSnapshot, refetch, loadQuery }; +} diff --git a/src/react/hooks/__tests__/useMutation/customScalars.test.tsx b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx index a7f5fd20736..8a3612f66ee 100644 --- a/src/react/hooks/__tests__/useMutation/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useMutation/customScalars.test.tsx @@ -5,13 +5,25 @@ import { import { delay, of } from "rxjs"; import type { OperationVariables, TypedDocumentNode } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { useMutation, useQuery } from "@apollo/client/react"; import { MockLink } from "@apollo/client/testing"; import { createClientWrapper, dateScalar, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, } from "@apollo/client/testing/internal"; test("serializes scalar variables used in field arguments", async () => { @@ -1107,3 +1119,588 @@ test("parses custom scalar fields in queries triggered by refetchQueries", async }, }); }); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { errorPolicy: "none" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect(mutate()).rejects.toEqual( + new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }) + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }), + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect(mutate()).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }), + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: new CombinedGraphQLErrors({ + data: { + createEvent: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }), + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["createEvent", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + await expect(mutate()).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate(); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + link.enqueueInitialChunk({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["createEvent"] }], + hasNext: false, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const mutation = gql` + mutation CreateEvent { + createEvent { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useMutation(mutation), + { wrapper: createClientWrapper(client) } + ); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: false, + loading: false, + }); + } + + const [mutate] = getCurrentSnapshot(); + + const promise = mutate(); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: undefined, + error: undefined, + called: true, + loading: true, + }); + } + + link.enqueueInitialChunk({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["createEvent"] }], + hasNext: true, + }); + + await expect(takeSnapshot).not.toRerender(); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const [, result] = await takeSnapshot(); + + expect(result).toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + error: undefined, + called: true, + loading: false, + }); + } + + await expect(promise).resolves.toStrictEqualTyped({ + data: { + createEvent: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx index a92bbd12abc..afa599abec3 100644 --- a/src/react/hooks/__tests__/useQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useQuery/customScalars.test.tsx @@ -5,12 +5,25 @@ import { import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { useQuery } from "@apollo/client/react"; import { createClientWrapper, dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, } from "@apollo/client/testing/internal"; test("serializes scalar variables used in field arguments", async () => { @@ -696,3 +709,811 @@ test.failing( }); } ); + +test("preserves referential identity when refetching identical serialized scalar values", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot, getCurrentSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { 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: {}, + }); + + const { data: previousData, refetch } = getCurrentSnapshot(); + + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.refetch, + 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: {}, + }); + + const { data } = getCurrentSnapshot(); + + expect(data).toBe(previousData); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "none" }), + { 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: undefined, + dataState: "empty", + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "all" }), + { 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), + endDate: null, + }, + }, + dataState: "complete", + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + loading: false, + networkStatus: NetworkStatus.error, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query, { errorPolicy: "ignore" }), + { 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), + endDate: null, + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: undefined, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeSnapshot } = await renderHookToSnapshotStream( + () => useQuery(query), + { wrapper: createClientWrapper(client) } + ); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: undefined, + dataState: "empty", + loading: true, + networkStatus: NetworkStatus.loading, + previousData: undefined, + variables: {}, + }); + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + loading: true, + networkStatus: NetworkStatus.streaming, + previousData: undefined, + variables: {}, + }); + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + await expect(takeSnapshot()).resolves.toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + loading: false, + networkStatus: NetworkStatus.ready, + previousData: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + variables: {}, + }); + + await expect(takeSnapshot).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx b/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx index 3f5f3beffbe..9d4f6078f1f 100644 --- a/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useQueryRefHandlers/customScalars.test.tsx @@ -103,9 +103,14 @@ test("serializes scalar variables passed to refetch", async () => { wrapper: createClientWrapper(client), }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useQueryRefHandlers", ""], - }); + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } { const { snapshot, renderedComponents } = await renderStream.takeRender(); @@ -135,9 +140,14 @@ test("serializes scalar variables passed to refetch", async () => { }, }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useQueryRefHandlers", ""], - }); + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } { const { snapshot, renderedComponents } = await renderStream.takeRender(); @@ -242,9 +252,14 @@ test("serializes scalar variables passed to fetchMore", async () => { wrapper: createClientWrapper(client), }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useQueryRefHandlers", ""], - }); + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } { const { snapshot, renderedComponents } = await renderStream.takeRender(); @@ -274,9 +289,14 @@ test("serializes scalar variables passed to fetchMore", async () => { }, }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useQueryRefHandlers", ""], - }); + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } { const { snapshot, renderedComponents } = await renderStream.takeRender(); @@ -388,9 +408,14 @@ test("serializes scalar variables passed to subscribeToMore", async () => { wrapper: createClientWrapper(client), }); - await expect(renderStream.takeRender()).resolves.toMatchObject({ - renderedComponents: ["useQueryRefHandlers", ""], - }); + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } { const { snapshot, renderedComponents } = await renderStream.takeRender(); @@ -445,3 +470,160 @@ test("serializes scalar variables passed to subscribeToMore", async () => { date: "2026-01-01", }); }); + +test("preserves referential identity when refetching identical serialized scalar values", async () => { + let refetchPromise!: ReturnType< + ReturnType["refetch"] + >; + + 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)) + ), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + const renderStream = createRenderStream({ + initialSnapshot: { + result: null as useReadQuery.Result | null, + }, + }); + + function ReadQuery() { + useTrackRenders({ name: "useReadQuery" }); + renderStream.mergeSnapshot({ result: useReadQuery(queryRef) }); + + return null; + } + + function Fallback() { + useTrackRenders({ name: "" }); + return null; + } + + function App() { + useTrackRenders({ name: "useQueryRefHandlers" }); + const { refetch } = useQueryRefHandlers(queryRef); + + return ( + <> + + }> + + + + ); + } + + const user = userEvent.setup(); + using _disabledAct = disableActEnvironment(); + await renderStream.render(, { + wrapper: createClientWrapper(client), + }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } + + let previousData: unknown; + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + previousData = snapshot.result!.data; + } + + await user.click(screen.getByText("Refetch")); + + await expect(refetchPromise).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual([ + "useQueryRefHandlers", + "", + ]); + } + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + + expect(snapshot.result!.data).toBe(previousData); + } + + await expect(renderStream).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx index 504ed0dc071..17154023002 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/customScalars.test.tsx @@ -2,12 +2,26 @@ import { disableActEnvironment } from "@testing-library/react-render-stream"; import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { useSuspenseQuery } from "@apollo/client/react"; import { createClientWrapper, dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, } from "@apollo/client/testing/internal"; import { renderUseSuspenseQuery } from "./testUtils.js"; @@ -629,3 +643,783 @@ test.failing( } } ); + +test("preserves referential identity when refetching identical serialized scalar values", async () => { + const query = gql` + query Event { + event { + id + startDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender, getCurrentSnapshot, refetch } = + await renderUseSuspenseQuery(() => useSuspenseQuery(query), { + wrapper: createClientWrapper(client), + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + const { data: previousData } = getCurrentSnapshot(); + + await expect(refetch()).resolves.toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + }); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + expect(getCurrentSnapshot().data).toBe(previousData); + + await expect(takeRender).not.toRerender(); +}); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + using _consoleSpy = spyOnConsole("error"); + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { errorPolicy: "none" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + expect(snapshot).toStrictEqualTyped({ + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { errorPolicy: "all" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query, { errorPolicy: "ignore" }), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + using _disabledAct = disableActEnvironment(); + const { takeRender } = await renderUseSuspenseQuery( + () => useSuspenseQuery(query), + { wrapper: createClientWrapper(client) } + ); + + { + const { renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual([""]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await takeRender(); + + expect(renderedComponents).toStrictEqual(["useSuspenseQuery"]); + expect(snapshot).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(takeRender).not.toRerender(); +}); diff --git a/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx b/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx index 8554c0f566e..9bf1e9d17a1 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery/testUtils.tsx @@ -4,6 +4,7 @@ import { useTrackRenders, } from "@testing-library/react-render-stream"; import React, { Suspense } from "react"; +import { flushSync } from "react-dom"; import { ErrorBoundary } from "react-error-boundary"; import type { ErrorLike, OperationVariables } from "@apollo/client"; @@ -69,5 +70,15 @@ export async function renderUseSuspenseQuery< return snapshot; } - return { getCurrentSnapshot, rerender, takeRender }; + // React 18 skips committing the suspense fallback when refetch is triggered + // as a default-priority update, so the fallback render is missed and tests + // may fail. flushSync forces a synchronous commit so the fallback renders in + // both React 18 and 19, while returning the refetch promise. + const refetch: useSuspenseQuery.Result["refetch"] = ( + ...args + ) => { + return flushSync(() => getCurrentSnapshot().refetch(...args)); + }; + + return { getCurrentSnapshot, rerender, takeRender, refetch }; } diff --git a/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx b/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx index 8ea858fed3d..a89e9bb7d51 100644 --- a/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx +++ b/src/react/query-preloader/__tests__/createQueryPreloader/customScalars.test.tsx @@ -1,11 +1,27 @@ import { disableActEnvironment } from "@testing-library/react-render-stream"; -import { of } from "rxjs"; +import { delay, of } from "rxjs"; import type { OperationVariables } from "@apollo/client"; -import { ApolloClient, ApolloLink, gql, NetworkStatus } from "@apollo/client"; +import { + ApolloClient, + ApolloLink, + CombinedGraphQLErrors, + gql, + NetworkStatus, +} from "@apollo/client"; import { InMemoryCache } from "@apollo/client/cache"; +import { + Defer20220824Handler, + GraphQL17Alpha9Handler, +} from "@apollo/client/incremental"; import { createQueryPreloader } from "@apollo/client/react"; -import { dateScalar } from "@apollo/client/testing/internal"; +import { + dateScalar, + markAsStreaming, + mockDefer20220824, + mockDeferStreamGraphQL17Alpha9, + spyOnConsole, +} from "@apollo/client/testing/internal"; import { renderDefaultTestApp } from "./testUtils.js"; @@ -158,3 +174,679 @@ test("serializes scalar fields in input object variables", async () => { filter: { date: "2026-01-01" }, }); }); + +test("serializes scalar fields in the error with a `none` error policy", async () => { + using _consoleSpy = spyOnConsole("error"); + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { errorPolicy: "none" }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["Error"]); + expect(snapshot.error).toEqual( + new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }) + ); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses scalar fields in the result and serializes them in the error with an `all` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { errorPolicy: "all" }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.error, + error: new CombinedGraphQLErrors({ + data: { + event: { + __typename: "Event", + id: "1", + // TODO: Determine if this is correct + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }), + }); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses custom scalar fields with an `ignore` error policy", async () => { + const query = gql` + query Event { + event { + id + startDate + endDate + } + } + `; + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: new ApolloLink(() => + of({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + endDate: null, + }, + }, + errors: [ + { + message: "Could not resolve endDate", + path: ["event", "endDate"], + }, + ], + }).pipe(delay(20)) + ), + }); + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query, { errorPolicy: "ignore" }); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: null, + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, path: ["event"] }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses custom scalar fields across `@defer` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + endDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Event { + event { + id + startDate + ... @defer { + endDate + } + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + link.enqueueInitialChunk({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + }, + pending: [{ id: "0", path: ["event"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: markAsStreaming({ + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + }), + dataState: "streaming", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [{ data: { endDate: "2026-02-02" }, id: "0" }], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + event: { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + endDate: new Date(2026, 1, 2), + }, + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (defer20220824)", async () => { + const link = mockDefer20220824(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new Defer20220824Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + path: ["events", 1], + }, + ], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); +}); + +test("parses custom scalar fields across `@stream` payloads (graphql17Alpha9)", async () => { + const link = mockDeferStreamGraphQL17Alpha9(); + const client = new ApolloClient({ + cache: new InMemoryCache({ + scalars: { Date: dateScalar }, + typePolicies: { + Event: { + fields: { + startDate: { scalar: "Date" }, + }, + }, + }, + }), + link: link.httpLink, + incrementalHandler: new GraphQL17Alpha9Handler(), + }); + const query = gql` + query Events { + events @stream(initialCount: 1) { + id + startDate + } + } + `; + + const preloadQuery = createQueryPreloader(client); + const queryRef = preloadQuery(query); + + using _disabledAct = disableActEnvironment(); + const { renderStream } = await renderDefaultTestApp({ client, queryRef }); + + { + const { renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["App", ""]); + } + + link.enqueueInitialChunk({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: "2026-01-01", + }, + ], + }, + pending: [{ id: "0", path: ["events"] }], + hasNext: true, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.streaming, + error: undefined, + }); + } + + link.enqueueSubsequentChunk({ + incremental: [ + { + items: [ + { + __typename: "Event", + id: "2", + startDate: "2026-02-02", + }, + ] as any, + id: "0", + }, + ], + completed: [{ id: "0" }], + hasNext: false, + }); + + { + const { snapshot, renderedComponents } = await renderStream.takeRender(); + + expect(renderedComponents).toStrictEqual(["useReadQuery"]); + expect(snapshot.result).toStrictEqualTyped({ + data: { + events: [ + { + __typename: "Event", + id: "1", + startDate: new Date(2026, 0, 1), + }, + { + __typename: "Event", + id: "2", + startDate: new Date(2026, 1, 2), + }, + ], + }, + dataState: "complete", + networkStatus: NetworkStatus.ready, + error: undefined, + }); + } + + await expect(renderStream).not.toRerender(); +});