Skip to content

Commit

Permalink
Merge pull request #11869 from apollographql/pr/rewrite-useQuery
Browse files Browse the repository at this point in the history
refactor useQuery to not use an internal class
  • Loading branch information
phryneas committed Jul 4, 2024
2 parents 98e44f7 + 33c0fef commit 5ae4876
Show file tree
Hide file tree
Showing 5 changed files with 804 additions and 548 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-olives-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": minor
---

Rewrite big parts of `useQuery` and `useLazyQuery` to be more compliant with the Rules of React and React Compiler
4 changes: 2 additions & 2 deletions .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 39619,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32852
"dist/apollo-client.min.cjs": 39825,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32851
}
29 changes: 1 addition & 28 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import { useApolloClient } from "../useApolloClient";
import { useLazyQuery } from "../useLazyQuery";

const IS_REACT_17 = React.version.startsWith("17");
const IS_REACT_19 = React.version.startsWith("19");

describe("useQuery Hook", () => {
describe("General use", () => {
Expand Down Expand Up @@ -1536,33 +1535,7 @@ describe("useQuery Hook", () => {

function checkObservableQueries(expectedLinkCount: number) {
const obsQueries = client.getObservableQueries("all");
/*
This is due to a timing change in React 19
In React 18, you observe this pattern:
1. render
2. useState initializer
3. component continues to render with first state
4. strictMode: render again
5. strictMode: call useState initializer again
6. component continues to render with second state
now, in React 19 it looks like this:
1. render
2. useState initializer
3. strictMode: call useState initializer again
4. component continues to render with one of these two states
5. strictMode: render again
6. component continues to render with the same state as during the first render
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.
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.
*/
expect(obsQueries.size).toBe(IS_REACT_19 ? 1 : 2);
expect(obsQueries.size).toBe(2);

const activeSet = new Set<typeof result.current.observable>();
const inactiveSet = new Set<typeof result.current.observable>();
Expand Down
136 changes: 117 additions & 19 deletions src/react/hooks/useLazyQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,30 @@ import type { DocumentNode } from "graphql";
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
import * as React from "rehackt";

import type { OperationVariables } from "../../core/index.js";
import type {
ApolloClient,
ApolloQueryResult,
OperationVariables,
WatchQueryOptions,
} from "../../core/index.js";
import { mergeOptions } from "../../utilities/index.js";
import type {
LazyQueryHookExecOptions,
LazyQueryHookOptions,
LazyQueryResultTuple,
NoInfer,
QueryHookOptions,
QueryResult,
} from "../types/types.js";
import { useInternalState } from "./useQuery.js";
import { useApolloClient } from "./useApolloClient.js";
import type { InternalResult, ObsQueryWithMeta } from "./useQuery.js";
import {
createMakeWatchQueryOptions,
getDefaultFetchPolicy,
getObsQueryOptions,
toQueryResult,
useQueryInternals,
} from "./useQuery.js";
import { useIsomorphicLayoutEffect } from "./internal/useIsomorphicLayoutEffect.js";

// The following methods, when called will execute the query, regardless of
// whether the useLazyQuery execute function was called before.
Expand All @@ -21,6 +35,7 @@ const EAGER_METHODS = [
"fetchMore",
"updateQuery",
"startPolling",
"stopPolling",
"subscribeToMore",
] as const;

Expand Down Expand Up @@ -80,21 +95,27 @@ export function useLazyQuery<
optionsRef.current = options;
queryRef.current = document;

const internalState = useInternalState<TData, TVariables>(
useApolloClient(options && options.client),
document
);

const useQueryResult = internalState.useQuery({
const queryHookOptions = {
...merged,
skip: !execOptionsRef.current,
});
};
const {
obsQueryFields,
result: useQueryResult,
client,
resultData,
observable,
onQueryExecuted,
} = useQueryInternals(document, queryHookOptions);

const initialFetchPolicy =
useQueryResult.observable.options.initialFetchPolicy ||
internalState.getDefaultFetchPolicy();
observable.options.initialFetchPolicy ||
getDefaultFetchPolicy(
queryHookOptions.defaultOptions,
client.defaultOptions
);

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

return eagerMethods;
return eagerMethods as typeof obsQueryFields;
}, [forceUpdateState, obsQueryFields]);

const called = !!execOptionsRef.current;
Expand Down Expand Up @@ -141,18 +162,95 @@ export function useLazyQuery<
...execOptionsRef.current,
});

const promise = internalState
.executeQuery({ ...options, skip: false })
.then((queryResult) => Object.assign(queryResult, eagerMethods));
const promise = executeQuery(
resultData,
observable,
client,
document,
{ ...options, skip: false },
onQueryExecuted
).then((queryResult) => Object.assign(queryResult, eagerMethods));

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

return promise;
},
[eagerMethods, initialFetchPolicy, internalState]
[
client,
document,
eagerMethods,
initialFetchPolicy,
observable,
resultData,
onQueryExecuted,
]
);

const executeRef = React.useRef(execute);
useIsomorphicLayoutEffect(() => {
executeRef.current = execute;
});

const stableExecute = React.useCallback<typeof execute>(
(...args) => executeRef.current(...args),
[]
);
return [stableExecute, result];
}

return [execute, result];
function executeQuery<TData, TVariables extends OperationVariables>(
resultData: InternalResult<TData, TVariables>,
observable: ObsQueryWithMeta<TData, TVariables>,
client: ApolloClient<object>,
currentQuery: DocumentNode,
options: QueryHookOptions<TData, TVariables> & {
query?: DocumentNode;
},
onQueryExecuted: (options: WatchQueryOptions<TVariables, TData>) => void
) {
const query = options.query || currentQuery;
const watchQueryOptions = createMakeWatchQueryOptions(
client,
query,
options,
false
)(observable);

const concast = observable.reobserveAsConcast(
getObsQueryOptions(observable, client, options, watchQueryOptions)
);
onQueryExecuted(watchQueryOptions);

return new Promise<
Omit<QueryResult<TData, TVariables>, (typeof EAGER_METHODS)[number]>
>((resolve) => {
let result: ApolloQueryResult<TData>;

// Subscribe to the concast independently of the ObservableQuery in case
// the component gets unmounted before the promise resolves. This prevents
// the concast from terminating early and resolving with `undefined` when
// there are no more subscribers for the concast.
concast.subscribe({
next: (value) => {
result = value;
},
error: () => {
resolve(
toQueryResult(
observable.getCurrentResult(),
resultData.previousData,
observable,
client
)
);
},
complete: () => {
resolve(
toQueryResult(result, resultData.previousData, observable, client)
);
},
});
});
}
Loading

0 comments on commit 5ae4876

Please sign in to comment.