Skip to content

Commit 5ae4876

Browse files
authored
Merge pull request #11869 from apollographql/pr/rewrite-useQuery
refactor useQuery to not use an internal class
2 parents 98e44f7 + 33c0fef commit 5ae4876

File tree

5 files changed

+804
-548
lines changed

5 files changed

+804
-548
lines changed

.changeset/nasty-olives-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apollo/client": minor
3+
---
4+
5+
Rewrite big parts of `useQuery` and `useLazyQuery` to be more compliant with the Rules of React and React Compiler

.size-limits.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"dist/apollo-client.min.cjs": 39619,
3-
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32852
2+
"dist/apollo-client.min.cjs": 39825,
3+
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32851
44
}

src/react/hooks/__tests__/useQuery.test.tsx

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import { useApolloClient } from "../useApolloClient";
3838
import { useLazyQuery } from "../useLazyQuery";
3939

4040
const IS_REACT_17 = React.version.startsWith("17");
41-
const IS_REACT_19 = React.version.startsWith("19");
4241

4342
describe("useQuery Hook", () => {
4443
describe("General use", () => {
@@ -1536,33 +1535,7 @@ describe("useQuery Hook", () => {
15361535

15371536
function checkObservableQueries(expectedLinkCount: number) {
15381537
const obsQueries = client.getObservableQueries("all");
1539-
/*
1540-
This is due to a timing change in React 19
1541-
1542-
In React 18, you observe this pattern:
1543-
1544-
1. render
1545-
2. useState initializer
1546-
3. component continues to render with first state
1547-
4. strictMode: render again
1548-
5. strictMode: call useState initializer again
1549-
6. component continues to render with second state
1550-
1551-
now, in React 19 it looks like this:
1552-
1553-
1. render
1554-
2. useState initializer
1555-
3. strictMode: call useState initializer again
1556-
4. component continues to render with one of these two states
1557-
5. strictMode: render again
1558-
6. component continues to render with the same state as during the first render
1559-
1560-
Since useQuery breaks the rules of React and mutably creates an ObservableQuery on the state during render if none is present, React 18 did create two, while React 19 only creates one.
1561-
1562-
This is pure coincidence though, and the useQuery rewrite that doesn't break the rules of hooks as much and creates the ObservableQuery as part of the state initializer will end up with behaviour closer to the old React 18 behaviour again.
1563-
1564-
*/
1565-
expect(obsQueries.size).toBe(IS_REACT_19 ? 1 : 2);
1538+
expect(obsQueries.size).toBe(2);
15661539

15671540
const activeSet = new Set<typeof result.current.observable>();
15681541
const inactiveSet = new Set<typeof result.current.observable>();

src/react/hooks/useLazyQuery.ts

Lines changed: 117 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,30 @@ import type { DocumentNode } from "graphql";
22
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
33
import * as React from "rehackt";
44

5-
import type { OperationVariables } from "../../core/index.js";
5+
import type {
6+
ApolloClient,
7+
ApolloQueryResult,
8+
OperationVariables,
9+
WatchQueryOptions,
10+
} from "../../core/index.js";
611
import { mergeOptions } from "../../utilities/index.js";
712
import type {
813
LazyQueryHookExecOptions,
914
LazyQueryHookOptions,
1015
LazyQueryResultTuple,
1116
NoInfer,
17+
QueryHookOptions,
18+
QueryResult,
1219
} from "../types/types.js";
13-
import { useInternalState } from "./useQuery.js";
14-
import { useApolloClient } from "./useApolloClient.js";
20+
import type { InternalResult, ObsQueryWithMeta } from "./useQuery.js";
21+
import {
22+
createMakeWatchQueryOptions,
23+
getDefaultFetchPolicy,
24+
getObsQueryOptions,
25+
toQueryResult,
26+
useQueryInternals,
27+
} from "./useQuery.js";
28+
import { useIsomorphicLayoutEffect } from "./internal/useIsomorphicLayoutEffect.js";
1529

1630
// The following methods, when called will execute the query, regardless of
1731
// whether the useLazyQuery execute function was called before.
@@ -21,6 +35,7 @@ const EAGER_METHODS = [
2135
"fetchMore",
2236
"updateQuery",
2337
"startPolling",
38+
"stopPolling",
2439
"subscribeToMore",
2540
] as const;
2641

@@ -80,21 +95,27 @@ export function useLazyQuery<
8095
optionsRef.current = options;
8196
queryRef.current = document;
8297

83-
const internalState = useInternalState<TData, TVariables>(
84-
useApolloClient(options && options.client),
85-
document
86-
);
87-
88-
const useQueryResult = internalState.useQuery({
98+
const queryHookOptions = {
8999
...merged,
90100
skip: !execOptionsRef.current,
91-
});
101+
};
102+
const {
103+
obsQueryFields,
104+
result: useQueryResult,
105+
client,
106+
resultData,
107+
observable,
108+
onQueryExecuted,
109+
} = useQueryInternals(document, queryHookOptions);
92110

93111
const initialFetchPolicy =
94-
useQueryResult.observable.options.initialFetchPolicy ||
95-
internalState.getDefaultFetchPolicy();
112+
observable.options.initialFetchPolicy ||
113+
getDefaultFetchPolicy(
114+
queryHookOptions.defaultOptions,
115+
client.defaultOptions
116+
);
96117

97-
const { forceUpdateState, obsQueryFields } = internalState;
118+
const forceUpdateState = React.useReducer((tick) => tick + 1, 0)[1];
98119
// We use useMemo here to make sure the eager methods have a stable identity.
99120
const eagerMethods = React.useMemo(() => {
100121
const eagerMethods: Record<string, any> = {};
@@ -111,7 +132,7 @@ export function useLazyQuery<
111132
};
112133
}
113134

114-
return eagerMethods;
135+
return eagerMethods as typeof obsQueryFields;
115136
}, [forceUpdateState, obsQueryFields]);
116137

117138
const called = !!execOptionsRef.current;
@@ -141,18 +162,95 @@ export function useLazyQuery<
141162
...execOptionsRef.current,
142163
});
143164

144-
const promise = internalState
145-
.executeQuery({ ...options, skip: false })
146-
.then((queryResult) => Object.assign(queryResult, eagerMethods));
165+
const promise = executeQuery(
166+
resultData,
167+
observable,
168+
client,
169+
document,
170+
{ ...options, skip: false },
171+
onQueryExecuted
172+
).then((queryResult) => Object.assign(queryResult, eagerMethods));
147173

148174
// Because the return value of `useLazyQuery` is usually floated, we need
149175
// to catch the promise to prevent unhandled rejections.
150176
promise.catch(() => {});
151177

152178
return promise;
153179
},
154-
[eagerMethods, initialFetchPolicy, internalState]
180+
[
181+
client,
182+
document,
183+
eagerMethods,
184+
initialFetchPolicy,
185+
observable,
186+
resultData,
187+
onQueryExecuted,
188+
]
189+
);
190+
191+
const executeRef = React.useRef(execute);
192+
useIsomorphicLayoutEffect(() => {
193+
executeRef.current = execute;
194+
});
195+
196+
const stableExecute = React.useCallback<typeof execute>(
197+
(...args) => executeRef.current(...args),
198+
[]
155199
);
200+
return [stableExecute, result];
201+
}
156202

157-
return [execute, result];
203+
function executeQuery<TData, TVariables extends OperationVariables>(
204+
resultData: InternalResult<TData, TVariables>,
205+
observable: ObsQueryWithMeta<TData, TVariables>,
206+
client: ApolloClient<object>,
207+
currentQuery: DocumentNode,
208+
options: QueryHookOptions<TData, TVariables> & {
209+
query?: DocumentNode;
210+
},
211+
onQueryExecuted: (options: WatchQueryOptions<TVariables, TData>) => void
212+
) {
213+
const query = options.query || currentQuery;
214+
const watchQueryOptions = createMakeWatchQueryOptions(
215+
client,
216+
query,
217+
options,
218+
false
219+
)(observable);
220+
221+
const concast = observable.reobserveAsConcast(
222+
getObsQueryOptions(observable, client, options, watchQueryOptions)
223+
);
224+
onQueryExecuted(watchQueryOptions);
225+
226+
return new Promise<
227+
Omit<QueryResult<TData, TVariables>, (typeof EAGER_METHODS)[number]>
228+
>((resolve) => {
229+
let result: ApolloQueryResult<TData>;
230+
231+
// Subscribe to the concast independently of the ObservableQuery in case
232+
// the component gets unmounted before the promise resolves. This prevents
233+
// the concast from terminating early and resolving with `undefined` when
234+
// there are no more subscribers for the concast.
235+
concast.subscribe({
236+
next: (value) => {
237+
result = value;
238+
},
239+
error: () => {
240+
resolve(
241+
toQueryResult(
242+
observable.getCurrentResult(),
243+
resultData.previousData,
244+
observable,
245+
client
246+
)
247+
);
248+
},
249+
complete: () => {
250+
resolve(
251+
toQueryResult(result, resultData.previousData, observable, client)
252+
);
253+
},
254+
});
255+
});
158256
}

0 commit comments

Comments
 (0)