diff --git a/.api-reports/api-report-react.md b/.api-reports/api-report-react.md index 16a48677e3e..99bf23e5739 100644 --- a/.api-reports/api-report-react.md +++ b/.api-reports/api-report-react.md @@ -807,6 +807,14 @@ interface FragmentMap { // @public (undocumented) type FragmentMatcher = (rootValue: any, typeCondition: string, context: any) => boolean; +// @public (undocumented) +interface FulfilledPromise extends Promise { + // (undocumented) + status: "fulfilled"; + // (undocumented) + value: TValue; +} + // @public (undocumented) export function getApolloContext(): ReactTypes.Context; @@ -858,7 +866,7 @@ class InternalQueryReference { // Warning: (ae-forgotten-export) The symbol "InternalQueryReferenceOptions" needs to be exported by the entry point index.d.ts constructor(observable: ObservableQuery, options: InternalQueryReferenceOptions); // (undocumented) - applyOptions(watchQueryOptions: ObservedOptions): Promise>; + applyOptions(watchQueryOptions: ObservedOptions): QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "ObservedOptions" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -877,10 +885,10 @@ class InternalQueryReference { listen(listener: Listener): () => void; // (undocumented) readonly observable: ObservableQuery; + // Warning: (ae-forgotten-export) The symbol "QueryRefPromise" needs to be exported by the entry point index.d.ts + // // (undocumented) - promise: Promise>; - // (undocumented) - promiseCache?: Map>>; + promise: QueryRefPromise; // (undocumented) refetch(variables: OperationVariables | undefined): Promise>; // (undocumented) @@ -966,7 +974,7 @@ export type LazyQueryResult = Quer export type LazyQueryResultTuple = [LazyQueryExecFunction, QueryResult]; // @public (undocumented) -type Listener = (promise: Promise>) => void; +type Listener = (promise: QueryRefPromise) => void; // @public (undocumented) export type LoadableQueryHookFetchPolicy = Extract; @@ -1363,9 +1371,25 @@ export namespace parser { // @public (undocumented) type Path = ReadonlyArray; +// @public (undocumented) +interface PendingPromise extends Promise { + // (undocumented) + status: "pending"; +} + // @public (undocumented) type Primitive = null | undefined | string | number | boolean | symbol | bigint; +// @public (undocumented) +const PROMISE_SYMBOL: unique symbol; + +// Warning: (ae-forgotten-export) The symbol "PendingPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FulfilledPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RejectedPromise" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type PromiseWithState = PendingPromise | FulfilledPromise | RejectedPromise; + // @public (undocumented) const QUERY_REFERENCE_SYMBOL: unique symbol; @@ -1619,12 +1643,19 @@ interface QueryOptions { // // @public export interface QueryReference { + // (undocumented) + [PROMISE_SYMBOL]: QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "InternalQueryReference" needs to be exported by the entry point index.d.ts // // (undocumented) - [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; + readonly [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; } +// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type QueryRefPromise = PromiseWithState>; + // @public (undocumented) export interface QueryResult extends ObservableQueryFields { // (undocumented) @@ -1738,6 +1769,14 @@ type RefetchQueryDescriptor = string | DocumentNode; // @public (undocumented) type RefetchWritePolicy = "merge" | "overwrite"; +// @public (undocumented) +interface RejectedPromise extends Promise { + // (undocumented) + reason: unknown; + // (undocumented) + status: "rejected"; +} + // @public (undocumented) class RenderPromises { // (undocumented) @@ -2218,8 +2257,8 @@ interface WatchQueryOptions boolean; +// @public (undocumented) +interface FulfilledPromise extends Promise { + // (undocumented) + status: "fulfilled"; + // (undocumented) + value: TValue; +} + // @public (undocumented) type GraphQLErrors = ReadonlyArray; @@ -805,7 +813,7 @@ class InternalQueryReference { // Warning: (ae-forgotten-export) The symbol "InternalQueryReferenceOptions" needs to be exported by the entry point index.d.ts constructor(observable: ObservableQuery, options: InternalQueryReferenceOptions); // (undocumented) - applyOptions(watchQueryOptions: ObservedOptions): Promise>; + applyOptions(watchQueryOptions: ObservedOptions): QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "ObservedOptions" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -824,10 +832,10 @@ class InternalQueryReference { listen(listener: Listener): () => void; // (undocumented) readonly observable: ObservableQuery; + // Warning: (ae-forgotten-export) The symbol "QueryRefPromise" needs to be exported by the entry point index.d.ts + // // (undocumented) - promise: Promise>; - // (undocumented) - promiseCache?: Map>>; + promise: QueryRefPromise; // (undocumented) refetch(variables: OperationVariables | undefined): Promise>; // (undocumented) @@ -917,7 +925,7 @@ interface LazyQueryHookOptions = [LazyQueryExecFunction, QueryResult]; // @public (undocumented) -type Listener = (promise: Promise>) => void; +type Listener = (promise: QueryRefPromise) => void; // @public (undocumented) type LoadableQueryHookFetchPolicy = Extract; @@ -1301,9 +1309,25 @@ type OperationVariables = Record; // @public (undocumented) type Path = ReadonlyArray; +// @public (undocumented) +interface PendingPromise extends Promise { + // (undocumented) + status: "pending"; +} + // @public (undocumented) type Primitive = null | undefined | string | number | boolean | symbol | bigint; +// @public (undocumented) +const PROMISE_SYMBOL: unique symbol; + +// Warning: (ae-forgotten-export) The symbol "PendingPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FulfilledPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RejectedPromise" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type PromiseWithState = PendingPromise | FulfilledPromise | RejectedPromise; + // @public (undocumented) const QUERY_REFERENCE_SYMBOL: unique symbol; @@ -1537,12 +1561,19 @@ interface QueryOptions { // // @public interface QueryReference { + // (undocumented) + [PROMISE_SYMBOL]: QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "InternalQueryReference" needs to be exported by the entry point index.d.ts // // (undocumented) - [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; + readonly [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; } +// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type QueryRefPromise = PromiseWithState>; + // Warning: (ae-forgotten-export) The symbol "ObservableQueryFields" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -1652,6 +1683,14 @@ type RefetchQueryDescriptor = string | DocumentNode; // @public (undocumented) type RefetchWritePolicy = "merge" | "overwrite"; +// @public (undocumented) +interface RejectedPromise extends Promise { + // (undocumented) + reason: unknown; + // (undocumented) + status: "rejected"; +} + // @public (undocumented) type RequestHandler = (operation: Operation, forward: NextLink) => Observable | null; @@ -2109,8 +2148,8 @@ interface WatchQueryOptions(promise: Promise): promise is PromiseWithState; @@ -1831,7 +1829,7 @@ export { print_2 as print } // Warning: (ae-forgotten-export) The symbol "PendingPromise" needs to be exported by the entry point index.d.ts // // @public (undocumented) -type PromiseWithState = PendingPromise | FulfilledPromise | RejectedPromise; +export type PromiseWithState = PendingPromise | FulfilledPromise | RejectedPromise; // @public (undocumented) class QueryInfo { diff --git a/.api-reports/api-report.md b/.api-reports/api-report.md index d6b96223bdf..dd96b473056 100644 --- a/.api-reports/api-report.md +++ b/.api-reports/api-report.md @@ -1025,6 +1025,14 @@ export function fromError(errorValue: any): Observable; // @public (undocumented) export function fromPromise(promise: Promise): Observable; +// @public (undocumented) +interface FulfilledPromise extends Promise { + // (undocumented) + status: "fulfilled"; + // (undocumented) + value: TValue; +} + // @public (undocumented) export function getApolloContext(): ReactTypes.Context; @@ -1204,7 +1212,7 @@ class InternalQueryReference { // Warning: (ae-forgotten-export) The symbol "InternalQueryReferenceOptions" needs to be exported by the entry point index.d.ts constructor(observable: ObservableQuery, options: InternalQueryReferenceOptions); // (undocumented) - applyOptions(watchQueryOptions: ObservedOptions): Promise>; + applyOptions(watchQueryOptions: ObservedOptions): QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "ObservedOptions" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1223,10 +1231,10 @@ class InternalQueryReference { listen(listener: Listener): () => void; // (undocumented) readonly observable: ObservableQuery; + // Warning: (ae-forgotten-export) The symbol "QueryRefPromise" needs to be exported by the entry point index.d.ts + // // (undocumented) - promise: Promise>; - // (undocumented) - promiseCache?: Map>>; + promise: QueryRefPromise; // (undocumented) refetch(variables: OperationVariables | undefined): Promise>; // (undocumented) @@ -1358,7 +1366,7 @@ export type LazyQueryResult = Quer export type LazyQueryResultTuple = [LazyQueryExecFunction, QueryResult]; // @public (undocumented) -type Listener = (promise: Promise>) => void; +type Listener = (promise: QueryRefPromise) => void; // @public (undocumented) export type LoadableQueryHookFetchPolicy = Extract; @@ -1844,6 +1852,12 @@ export namespace parser { // @public (undocumented) export type Path = ReadonlyArray; +// @public (undocumented) +interface PendingPromise extends Promise { + // (undocumented) + status: "pending"; +} + // @public (undocumented) class Policies { constructor(config: { @@ -1907,6 +1921,16 @@ interface Printer { (node: ASTNode, originalPrint: typeof print_2): string; } +// @public (undocumented) +const PROMISE_SYMBOL: unique symbol; + +// Warning: (ae-forgotten-export) The symbol "PendingPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FulfilledPromise" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RejectedPromise" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type PromiseWithState = PendingPromise | FulfilledPromise | RejectedPromise; + // @public (undocumented) const QUERY_REFERENCE_SYMBOL: unique symbol; @@ -2153,12 +2177,19 @@ export { QueryOptions } // // @public export interface QueryReference { + // (undocumented) + [PROMISE_SYMBOL]: QueryRefPromise; // Warning: (ae-forgotten-export) The symbol "InternalQueryReference" needs to be exported by the entry point index.d.ts // // (undocumented) - [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; + readonly [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; } +// Warning: (ae-forgotten-export) The symbol "PromiseWithState" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type QueryRefPromise = PromiseWithState>; + // @public (undocumented) export interface QueryResult extends ObservableQueryFields { // (undocumented) @@ -2286,6 +2317,14 @@ export type RefetchQueryDescriptor = string | DocumentNode; // @public (undocumented) export type RefetchWritePolicy = "merge" | "overwrite"; +// @public (undocumented) +interface RejectedPromise extends Promise { + // (undocumented) + reason: unknown; + // (undocumented) + status: "rejected"; +} + // @public (undocumented) class RenderPromises { // (undocumented) @@ -2862,8 +2901,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/QueryManager.ts:395:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:253:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:26:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts -// src/react/hooks/useBackgroundQuery.ts:27:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:30:3 - (ae-forgotten-export) The symbol "FetchMoreFunction" needs to be exported by the entry point index.d.ts +// src/react/hooks/useBackgroundQuery.ts:31:3 - (ae-forgotten-export) The symbol "RefetchFunction" needs to be exported by the entry point index.d.ts // src/react/hooks/useLoadableQuery.ts:49:5 - (ae-forgotten-export) The symbol "ResetFunction" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/.size-limits.json b/.size-limits.json index 6f8c966652b..c1d55c13153 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 38632, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32319 + "dist/apollo-client.min.cjs": 38618, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32318 } diff --git a/src/react/cache/QueryReference.ts b/src/react/cache/QueryReference.ts index 7da866b5309..97025c37335 100644 --- a/src/react/cache/QueryReference.ts +++ b/src/react/cache/QueryReference.ts @@ -7,28 +7,37 @@ import type { WatchQueryOptions, } from "../../core/index.js"; import { isNetworkRequestSettled } from "../../core/index.js"; -import type { ObservableSubscription } from "../../utilities/index.js"; +import type { + ObservableSubscription, + PromiseWithState, +} from "../../utilities/index.js"; import { createFulfilledPromise, createRejectedPromise, } from "../../utilities/index.js"; import type { QueryKey } from "./types.js"; import type { useBackgroundQuery, useReadQuery } from "../hooks/index.js"; +import { wrapPromiseWithState } from "../../utilities/index.js"; -type Listener = (promise: Promise>) => void; +type QueryRefPromise = PromiseWithState>; + +type Listener = (promise: QueryRefPromise) => void; type FetchMoreOptions = Parameters< ObservableQuery["fetchMore"] >[0]; const QUERY_REFERENCE_SYMBOL: unique symbol = Symbol(); +const PROMISE_SYMBOL: unique symbol = Symbol(); + /** * A `QueryReference` is an opaque object returned by {@link useBackgroundQuery}. * A child component reading the `QueryReference` via {@link useReadQuery} will * suspend until the promise resolves. */ export interface QueryReference { - [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; + readonly [QUERY_REFERENCE_SYMBOL]: InternalQueryReference; + [PROMISE_SYMBOL]: QueryRefPromise; } interface InternalQueryReferenceOptions { @@ -39,13 +48,34 @@ interface InternalQueryReferenceOptions { export function wrapQueryRef( internalQueryRef: InternalQueryReference ): QueryReference { - return { [QUERY_REFERENCE_SYMBOL]: internalQueryRef }; + return { + [QUERY_REFERENCE_SYMBOL]: internalQueryRef, + [PROMISE_SYMBOL]: internalQueryRef.promise, + }; } export function unwrapQueryRef( queryRef: QueryReference -): InternalQueryReference { - return queryRef[QUERY_REFERENCE_SYMBOL]; +): [InternalQueryReference, () => QueryRefPromise] { + const internalQueryRef = queryRef[QUERY_REFERENCE_SYMBOL]; + + return [ + internalQueryRef, + () => + // There is a chance the query ref's promise has been updated in the time + // the original promise had been suspended. In that case, we want to use + // it instead of the older promise which may contain outdated data. + internalQueryRef.promise.status === "fulfilled" ? + internalQueryRef.promise + : queryRef[PROMISE_SYMBOL], + ]; +} + +export function updateWrappedQueryRef( + queryRef: QueryReference, + promise: QueryRefPromise +) { + queryRef[PROMISE_SYMBOL] = promise; } const OBSERVED_CHANGED_OPTIONS = [ @@ -67,8 +97,7 @@ export class InternalQueryReference { public readonly key: QueryKey = {}; public readonly observable: ObservableQuery; - public promiseCache?: Map>>; - public promise: Promise>; + public promise: QueryRefPromise; private subscription: ObservableSubscription; private listeners = new Set>(); @@ -104,10 +133,12 @@ export class InternalQueryReference { this.promise = createFulfilledPromise(this.result); this.status = "idle"; } else { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); + this.promise = wrapPromiseWithState( + new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }) + ); } this.subscription = observable @@ -268,23 +299,25 @@ export class InternalQueryReference { break; } case "idle": { - this.promise = createRejectedPromise(error); + this.promise = createRejectedPromise>(error); this.deliver(this.promise); } } } - private deliver(promise: Promise>) { + private deliver(promise: QueryRefPromise) { this.listeners.forEach((listener) => listener(promise)); } private initiateFetch(returnedPromise: Promise>) { this.status = "loading"; - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); + this.promise = wrapPromiseWithState( + new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }) + ); this.promise.catch(() => {}); diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index 32bd0edcdc0..85b857e47a5 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -216,6 +216,7 @@ function renderVariablesIntegrationTest({ }, }, }, + delay: 200, }; } ); @@ -642,7 +643,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { hello: "world 1" }, @@ -679,7 +680,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; await waitFor(() => { expect(_result).toEqual({ @@ -720,7 +721,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; await waitFor(() => { expect(_result).toMatchObject({ @@ -780,7 +781,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; const resultSet = new Set(_result.data.results); const values = Array.from(resultSet).map((item) => item.value); @@ -841,7 +842,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; const resultSet = new Set(_result.data.results); const values = Array.from(resultSet).map((item) => item.value); @@ -883,7 +884,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { hello: "from link" }, @@ -923,7 +924,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { hello: "from cache" }, @@ -970,7 +971,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { foo: "bar", hello: "from link" }, @@ -1010,7 +1011,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { hello: "from link" }, @@ -1053,7 +1054,7 @@ describe("useBackgroundQuery", () => { const [queryRef] = result.current; - const _result = await unwrapQueryRef(queryRef).promise; + const _result = await unwrapQueryRef(queryRef)[0].promise; expect(_result).toEqual({ data: { hello: "from link" }, @@ -3224,12 +3225,14 @@ describe("useBackgroundQuery", () => { result: { data: { character: { id: "1", name: "Captain Marvel" } }, }, + delay: 200, }, { request: { query, variables: { id: "2" } }, result: { data: { character: { id: "2", name: "Captain America" } }, }, + delay: 200, }, ]; @@ -3294,7 +3297,7 @@ describe("useBackgroundQuery", () => { // parent component re-suspends expect(renders.suspenseCount).toBe(2); - expect(renders.count).toBe(2); + expect(renders.count).toBe(1); expect( await screen.findByText("1 - Spider-Man (updated)") @@ -3304,11 +3307,13 @@ describe("useBackgroundQuery", () => { // parent component re-suspends expect(renders.suspenseCount).toBe(3); - expect(renders.count).toBe(3); + expect(renders.count).toBe(2); expect( await screen.findByText("1 - Spider-Man (updated again)") ).toBeInTheDocument(); + + expect(renders.count).toBe(3); }); it("throws errors when errors are returned after calling `refetch`", async () => { using _consoleSpy = spyOnConsole("error"); diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index cbb72efdf94..8bb0a37b2b9 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -158,6 +158,7 @@ function createDefaultProfiler() { error: null as Error | null, result: null as UseReadQueryResult | null, }, + skipNonTrackingRenders: true, }); } @@ -493,16 +494,24 @@ it("allows the client to be overridden", async () => { const { query } = useSimpleQueryCase(); const globalClient = new ApolloClient({ - link: new ApolloLink(() => - Observable.of({ data: { greeting: "global hello" } }) - ), + link: new MockLink([ + { + request: { query }, + result: { data: { greeting: "global hello" } }, + delay: 10, + }, + ]), cache: new InMemoryCache(), }); const localClient = new ApolloClient({ - link: new ApolloLink(() => - Observable.of({ data: { greeting: "local hello" } }) - ), + link: new MockLink([ + { + request: { query }, + result: { data: { greeting: "local hello" } }, + delay: 10, + }, + ]), cache: new InMemoryCache(), }); @@ -512,6 +521,7 @@ it("allows the client to be overridden", async () => { createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef] = useLoadableQuery(query, { client: localClient, }); @@ -562,9 +572,10 @@ it("passes context to the link", async () => { link: new ApolloLink((operation) => { return new Observable((observer) => { const { valueA, valueB } = operation.getContext(); - - observer.next({ data: { context: { valueA, valueB } } }); - observer.complete(); + setTimeout(() => { + observer.next({ data: { context: { valueA, valueB } } }); + observer.complete(); + }, 10); }); }), }); @@ -575,6 +586,7 @@ it("passes context to the link", async () => { createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef] = useLoadableQuery(query, { context: { valueA: "A", valueB: "B" }, }); @@ -657,6 +669,7 @@ it('enables canonical results when canonizeResults is "true"', async () => { createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef] = useLoadableQuery(query, { canonizeResults: true, }); @@ -738,6 +751,7 @@ it("can disable canonical results when the cache's canonizeResults setting is tr createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef] = useLoadableQuery(query, { canonizeResults: false, }); @@ -1456,12 +1470,14 @@ it("applies `errorPolicy` on next fetch when it changes between renders", async { request: { query }, result: { data: { greeting: "Hello" } }, + delay: 10, }, { request: { query }, result: { errors: [new GraphQLError("oops")], }, + delay: 10, }, ]; @@ -1470,6 +1486,7 @@ it("applies `errorPolicy` on next fetch when it changes between renders", async createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [errorPolicy, setErrorPolicy] = useState("none"); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { errorPolicy, @@ -1541,10 +1558,15 @@ it("applies `context` on next fetch when it changes between renders", async () = `; const link = new ApolloLink((operation) => { - return Observable.of({ - data: { - phase: operation.getContext().phase, - }, + return new Observable((subscriber) => { + setTimeout(() => { + subscriber.next({ + data: { + phase: operation.getContext().phase, + }, + }); + subscriber.complete(); + }, 10); }); }); @@ -1558,6 +1580,7 @@ it("applies `context` on next fetch when it changes between renders", async () = createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [phase, setPhase] = React.useState("initial"); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { context: { phase }, @@ -1659,6 +1682,7 @@ it("returns canonical results immediately when `canonizeResults` changes from `f createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [canonizeResults, setCanonizeResults] = React.useState(false); const [loadQuery, queryRef] = useLoadableQuery(query, { canonizeResults, @@ -1724,6 +1748,7 @@ it("applies changed `refetchWritePolicy` to next fetch when changing between ren { request: { query, variables: { min: 0, max: 12 } }, result: { data: { primes: [2, 3, 5, 7, 11] } }, + delay: 10, }, { request: { query, variables: { min: 12, max: 30 } }, @@ -1765,6 +1790,7 @@ it("applies changed `refetchWritePolicy` to next fetch when changing between ren createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [refetchWritePolicy, setRefetchWritePolicy] = React.useState("merge"); @@ -1895,6 +1921,7 @@ it("applies `returnPartialData` on next fetch when it changes between renders", }, }, }, + delay: 10, }, { request: { query: fullQuery }, @@ -2067,6 +2094,7 @@ it("applies updated `fetchPolicy` on next fetch when it changes between renders" createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [fetchPolicy, setFetchPolicy] = React.useState("cache-first"); @@ -2237,12 +2265,14 @@ it("re-suspends when calling `refetch` with new variables", async () => { result: { data: { character: { id: "1", name: "Captain Marvel" } }, }, + delay: 10, }, { request: { query, variables: { id: "2" } }, result: { data: { character: { id: "2", name: "Captain America" } }, }, + delay: 10, }, ]; @@ -2319,6 +2349,7 @@ it("re-suspends multiple times when calling `refetch` multiple times", async () data: { character: { id: "1", name: "Spider-Man" } }, }, maxUsageCount: 3, + delay: 10, }, ]; @@ -2434,6 +2465,7 @@ it("throws errors when errors are returned after calling `refetch`", async () => createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); return ( @@ -2491,12 +2523,14 @@ it('ignores errors returned after calling `refetch` when errorPolicy is set to " result: { data: { character: { id: "1", name: "Captain Marvel" } }, }, + delay: 10, }, { request: { query, variables: { id: "1" } }, result: { errors: [new GraphQLError("Something went wrong")], }, + delay: 10, }, ]; @@ -2506,6 +2540,7 @@ it('ignores errors returned after calling `refetch` when errorPolicy is set to " createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { errorPolicy: "ignore", }); @@ -2586,6 +2621,7 @@ it('returns errors after calling `refetch` when errorPolicy is set to "all"', as createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { errorPolicy: "all", }); @@ -2668,6 +2704,7 @@ it('handles partial data results after calling `refetch` when errorPolicy is set createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { errorPolicy: "all", }); @@ -2933,6 +2970,7 @@ it("properly uses `updateQuery` when calling `fetchMore`", async () => { createDefaultProfiledComponents(Profiler); function App() { + useTrackRenders(); const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); return ( @@ -3000,6 +3038,9 @@ it("properly uses `updateQuery` when calling `fetchMore`", async () => { }); } + // TODO investigate: this test highlights a React render + // that actually doesn't rerender any user-provided components + // so we need to use `skipNonTrackingRenders` await expect(Profiler).not.toRerender(); }); @@ -3023,6 +3064,7 @@ it("properly uses cache field policies when calling `fetchMore` without `updateQ }); function App() { + useTrackRenders(); const [loadQuery, queryRef, { fetchMore }] = useLoadableQuery(query); return ( @@ -3083,6 +3125,9 @@ it("properly uses cache field policies when calling `fetchMore` without `updateQ }); } + // TODO investigate: this test highlights a React render + // that actually doesn't rerender any user-provided components + // so we need to use `skipNonTrackingRenders` await expect(Profiler).not.toRerender(); }); @@ -3269,6 +3314,7 @@ it('honors refetchWritePolicy set to "merge"', async () => { { request: { query, variables: { min: 0, max: 12 } }, result: { data: { primes: [2, 3, 5, 7, 11] } }, + delay: 10, }, { request: { query, variables: { min: 12, max: 30 } }, @@ -3304,6 +3350,7 @@ it('honors refetchWritePolicy set to "merge"', async () => { }); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query, { refetchWritePolicy: "merge", }); @@ -3381,6 +3428,7 @@ it('defaults refetchWritePolicy to "overwrite"', async () => { { request: { query, variables: { min: 0, max: 12 } }, result: { data: { primes: [2, 3, 5, 7, 11] } }, + delay: 10, }, { request: { query, variables: { min: 12, max: 30 } }, @@ -3416,6 +3464,7 @@ it('defaults refetchWritePolicy to "overwrite"', async () => { }); function App() { + useTrackRenders(); const [loadQuery, queryRef, { refetch }] = useLoadableQuery(query); return ( @@ -3691,6 +3740,7 @@ it('suspends when partial data is in the cache and using a "network-only" fetch { request: { query: fullQuery }, result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + delay: 10, }, ]; @@ -3782,6 +3832,7 @@ it('suspends when partial data is in the cache and using a "no-cache" fetch poli { request: { query: fullQuery }, result: { data: { character: { id: "1", name: "Doctor Strange" } } }, + delay: 10, }, ]; diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index d6d4af2789b..47484ad45b8 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -7,7 +7,11 @@ import type { WatchQueryOptions, } from "../../core/index.js"; import { useApolloClient } from "./useApolloClient.js"; -import { wrapQueryRef } from "../cache/QueryReference.js"; +import { + unwrapQueryRef, + updateWrappedQueryRef, + wrapQueryRef, +} from "../cache/QueryReference.js"; import type { QueryReference } from "../cache/QueryReference.js"; import type { BackgroundQueryHookOptions, NoInfer } from "../types/types.js"; import { __use } from "./internal/index.js"; @@ -201,13 +205,15 @@ export function useBackgroundQuery< client.watchQuery(watchQueryOptions as WatchQueryOptions) ); - const [promiseCache, setPromiseCache] = React.useState( - () => new Map([[queryRef.key, queryRef.promise]]) + const [wrappedQueryRef, setWrappedQueryRef] = React.useState( + wrapQueryRef(queryRef) ); - + if (unwrapQueryRef(wrappedQueryRef)[0] !== queryRef) { + setWrappedQueryRef(wrapQueryRef(queryRef)); + } if (queryRef.didChangeOptions(watchQueryOptions)) { const promise = queryRef.applyOptions(watchQueryOptions); - promiseCache.set(queryRef.key, promise); + updateWrappedQueryRef(wrappedQueryRef, promise); } React.useEffect(() => queryRef.retain(), [queryRef]); @@ -216,9 +222,7 @@ export function useBackgroundQuery< (options) => { const promise = queryRef.fetchMore(options as FetchMoreQueryOptions); - setPromiseCache((promiseCache) => - new Map(promiseCache).set(queryRef.key, queryRef.promise) - ); + setWrappedQueryRef(wrapQueryRef(queryRef)); return promise; }, @@ -229,22 +233,13 @@ export function useBackgroundQuery< (variables) => { const promise = queryRef.refetch(variables); - setPromiseCache((promiseCache) => - new Map(promiseCache).set(queryRef.key, queryRef.promise) - ); + setWrappedQueryRef(wrapQueryRef(queryRef)); return promise; }, [queryRef] ); - queryRef.promiseCache = promiseCache; - - const wrappedQueryRef = React.useMemo( - () => wrapQueryRef(queryRef), - [queryRef] - ); - return [ didFetchResult.current ? wrappedQueryRef : void 0, { fetchMore, refetch }, diff --git a/src/react/hooks/useLoadableQuery.ts b/src/react/hooks/useLoadableQuery.ts index 771c02afc5f..558479c52f6 100644 --- a/src/react/hooks/useLoadableQuery.ts +++ b/src/react/hooks/useLoadableQuery.ts @@ -130,10 +130,6 @@ export function useLoadableQuery< promiseCache.set(queryRef.key, promise); } - if (queryRef) { - queryRef.promiseCache = promiseCache; - } - const calledDuringRender = useRenderGuard(); React.useEffect(() => queryRef?.retain(), [queryRef]); diff --git a/src/react/hooks/useReadQuery.ts b/src/react/hooks/useReadQuery.ts index 803535c4878..f2320aa58ea 100644 --- a/src/react/hooks/useReadQuery.ts +++ b/src/react/hooks/useReadQuery.ts @@ -1,9 +1,11 @@ import * as React from "rehackt"; -import { unwrapQueryRef } from "../cache/QueryReference.js"; +import { + unwrapQueryRef, + updateWrappedQueryRef, +} from "../cache/QueryReference.js"; import type { QueryReference } from "../cache/QueryReference.js"; import { __use } from "./internal/index.js"; import { toApolloError } from "./useSuspenseQuery.js"; -import { invariant } from "../../utilities/globals/index.js"; import { useSyncExternalStore } from "./useSyncExternalStore.js"; import type { ApolloError } from "../../errors/index.js"; import type { NetworkStatus } from "../../core/index.js"; @@ -36,32 +38,23 @@ export interface UseReadQueryResult { export function useReadQuery( queryRef: QueryReference ): UseReadQueryResult { - const internalQueryRef = unwrapQueryRef(queryRef); - invariant( - internalQueryRef.promiseCache, - "It appears that `useReadQuery` was used outside of `useBackgroundQuery`. " + - "`useReadQuery` is only supported for use with `useBackgroundQuery`. " + - "Please ensure you are passing the `queryRef` returned from `useBackgroundQuery`." + const [internalQueryRef, getPromise] = React.useMemo( + () => unwrapQueryRef(queryRef), + [queryRef] ); - const { promiseCache, key } = internalQueryRef; - - if (!promiseCache.has(key)) { - promiseCache.set(key, internalQueryRef.promise); - } - const promise = useSyncExternalStore( React.useCallback( (forceUpdate) => { return internalQueryRef.listen((promise) => { - internalQueryRef.promiseCache!.set(internalQueryRef.key, promise); + updateWrappedQueryRef(queryRef, promise); forceUpdate(); }); }, [internalQueryRef] ), - () => promiseCache.get(key)!, - () => promiseCache.get(key)! + getPromise, + getPromise ); const result = __use(promise); diff --git a/src/testing/internal/profile/profile.tsx b/src/testing/internal/profile/profile.tsx index 9257ea4f203..12e681ad7d2 100644 --- a/src/testing/internal/profile/profile.tsx +++ b/src/testing/internal/profile/profile.tsx @@ -98,17 +98,8 @@ export interface ProfiledComponent export function profile({ Component, ...options -}: { - onRender?: ( - info: BaseRender & { - snapshot: Snapshot; - replaceSnapshot: ReplaceSnapshot; - mergeSnapshot: MergeSnapshot; - } - ) => void; +}: Parameters>[0] & { Component: React.ComponentType; - snapshotDOM?: boolean; - initialSnapshot?: Snapshot; }): ProfiledComponent { const Profiler = createProfiler(options); @@ -140,6 +131,7 @@ export function createProfiler({ onRender, snapshotDOM = false, initialSnapshot, + skipNonTrackingRenders, }: { onRender?: ( info: BaseRender & { @@ -150,6 +142,11 @@ export function createProfiler({ ) => void; snapshotDOM?: boolean; initialSnapshot?: Snapshot; + /** + * This will skip renders during which no renders tracked by + * `useTrackRenders` occured. + */ + skipNonTrackingRenders?: boolean; } = {}) { let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; @@ -194,6 +191,12 @@ export function createProfiler({ startTime, commitTime ) => { + if ( + skipNonTrackingRenders && + profilerContext.renderedComponents.length === 0 + ) { + return; + } const baseRender = { id, phase, diff --git a/src/utilities/index.ts b/src/utilities/index.ts index ec05d2aa043..35c1ec45cad 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -98,6 +98,7 @@ export type { } from "./observables/Observable.js"; export { Observable } from "./observables/Observable.js"; +export type { PromiseWithState } from "./promises/decoration.js"; export { isStatefulPromise, createFulfilledPromise,