diff --git a/.changeset/nasty-olives-act.md b/.changeset/nasty-olives-act.md new file mode 100644 index 0000000000..02fadec8ca --- /dev/null +++ b/.changeset/nasty-olives-act.md @@ -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 diff --git a/.size-limits.json b/.size-limits.json index 957c176449..79e71c0699 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -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 } diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index bfcd534c7e..7909f8673d 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -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", () => { @@ -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(); const inactiveSet = new Set(); diff --git a/src/react/hooks/useLazyQuery.ts b/src/react/hooks/useLazyQuery.ts index a8d6eb00a6..911d2b9e69 100644 --- a/src/react/hooks/useLazyQuery.ts +++ b/src/react/hooks/useLazyQuery.ts @@ -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. @@ -21,6 +35,7 @@ const EAGER_METHODS = [ "fetchMore", "updateQuery", "startPolling", + "stopPolling", "subscribeToMore", ] as const; @@ -80,21 +95,27 @@ export function useLazyQuery< optionsRef.current = options; queryRef.current = document; - const internalState = useInternalState( - 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 = {}; @@ -111,7 +132,7 @@ export function useLazyQuery< }; } - return eagerMethods; + return eagerMethods as typeof obsQueryFields; }, [forceUpdateState, obsQueryFields]); const called = !!execOptionsRef.current; @@ -141,9 +162,14 @@ 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. @@ -151,8 +177,80 @@ export function useLazyQuery< 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( + (...args) => executeRef.current(...args), + [] ); + return [stableExecute, result]; +} - return [execute, result]; +function executeQuery( + resultData: InternalResult, + observable: ObsQueryWithMeta, + client: ApolloClient, + currentQuery: DocumentNode, + options: QueryHookOptions & { + query?: DocumentNode; + }, + onQueryExecuted: (options: WatchQueryOptions) => 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, (typeof EAGER_METHODS)[number]> + >((resolve) => { + let result: ApolloQueryResult; + + // 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) + ); + }, + }); + }); } diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index 61ca66527b..f3ef9aacb0 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,3 +1,22 @@ +/** + * Function parameters in this file try to follow a common order for the sake of + * readability and consistency. The order is as follows: + * + * resultData + * observable + * client + * query + * options + * watchQueryOptions + * makeWatchQueryOptions + * isSSRAllowed + * disableNetworkFetches + * partialRefetch + * renderPromises + * isSyncSSR + * callbacks + */ +/** */ import { invariant } from "../../utilities/globals/index.js"; import * as React from "rehackt"; @@ -5,15 +24,15 @@ import { useSyncExternalStore } from "./useSyncExternalStore.js"; import { equal } from "@wry/equality"; import type { + ApolloClient, + DefaultOptions, OperationVariables, WatchQueryFetchPolicy, } from "../../core/index.js"; import { mergeOptions } from "../../utilities/index.js"; -import type { ApolloContextValue } from "../context/index.js"; import { getApolloContext } from "../context/index.js"; import { ApolloError } from "../../errors/index.js"; import type { - ApolloClient, ApolloQueryResult, ObservableQuery, DocumentNode, @@ -31,17 +50,61 @@ import type { import { DocumentType, verifyDocumentType } from "../parser/index.js"; import { useApolloClient } from "./useApolloClient.js"; import { - canUseWeakMap, compact, isNonEmptyArray, maybeDeepFreeze, } from "../../utilities/index.js"; import { wrapHook } from "./internal/index.js"; +import type { RenderPromises } from "../ssr/RenderPromises.js"; const { prototype: { hasOwnProperty }, } = Object; +const originalResult = Symbol(); +interface InternalQueryResult + extends Omit< + QueryResult, + Exclude, "variables"> + > { + [originalResult]: ApolloQueryResult; +} + +function noop() {} +export const lastWatchOptions = Symbol(); + +export interface ObsQueryWithMeta + extends ObservableQuery { + [lastWatchOptions]?: WatchQueryOptions; +} + +export interface InternalResult { + // These members are populated by getCurrentResult and setResult, and it's + // okay/normal for them to be initially undefined. + current?: undefined | InternalQueryResult; + previousData?: undefined | TData; +} + +interface InternalState { + client: ReturnType; + query: DocumentNode | TypedDocumentNode; + observable: ObsQueryWithMeta; + resultData: InternalResult; +} + +export type UpdateInternalState< + TData, + TVariables extends OperationVariables, +> = (state: InternalState) => void; + +interface Callbacks { + // Defining these methods as no-ops on the prototype allows us to call + // state.onCompleted and/or state.onError without worrying about whether a + // callback was provided. + onCompleted(data: TData): void; + onError(error: ApolloError): void; +} + /** * A hook for executing queries in an Apollo application. * @@ -100,360 +163,437 @@ function _useQuery< query: DocumentNode | TypedDocumentNode, options: QueryHookOptions, NoInfer> ) { - return useInternalState(useApolloClient(options.client), query).useQuery( - options + const { result, obsQueryFields } = useQueryInternals(query, options); + return React.useMemo( + () => ({ ...result, ...obsQueryFields }), + [result, obsQueryFields] ); } -export function useInternalState( - client: ApolloClient, - query: DocumentNode | TypedDocumentNode -): InternalState { - // By default, InternalState.prototype.forceUpdate is an empty function, but - // we replace it here (before anyone has had a chance to see this state yet) - // with a function that unconditionally forces an update, using the latest - // setTick function. Updating this state by calling state.forceUpdate or the - // uSES notification callback are the only way we trigger React component updates. - const forceUpdateState = React.useReducer((tick) => tick + 1, 0)[1]; - +function useInternalState< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + client: ApolloClient, + query: DocumentNode | TypedDocumentNode, + options: QueryHookOptions, NoInfer>, + renderPromises: RenderPromises | undefined, + makeWatchQueryOptions: () => WatchQueryOptions +) { function createInternalState(previous?: InternalState) { - return Object.assign(new InternalState(client, query, previous), { - forceUpdateState, - }); + verifyDocumentType(query, DocumentType.Query); + + const internalState: InternalState = { + client, + query, + observable: + // See if there is an existing observable that was used to fetch the same + // data and if so, use it instead since it will contain the proper queryId + // to fetch the result set. This is used during SSR. + (renderPromises && + renderPromises.getSSRObservable(makeWatchQueryOptions())) || + client.watchQuery( + getObsQueryOptions(void 0, client, options, makeWatchQueryOptions()) + ), + resultData: { + // Reuse previousData from previous InternalState (if any) to provide + // continuity of previousData even if/when the query or client changes. + previousData: previous?.resultData.current?.data, + }, + }; + + return internalState as InternalState; } - let [state, updateState] = React.useState(createInternalState); + let [internalState, updateInternalState] = + React.useState(createInternalState); + + /** + * Used by `useLazyQuery` when a new query is executed. + * We keep this logic here since it needs to update things in unsafe + * ways and here we at least can keep track of that in a single place. + */ + function onQueryExecuted( + watchQueryOptions: WatchQueryOptions + ) { + // this needs to be set to prevent an immediate `resubscribe` in the + // next rerender of the `useQuery` internals + Object.assign(internalState.observable, { + [lastWatchOptions]: watchQueryOptions, + }); + const resultData = internalState.resultData; + updateInternalState({ + ...internalState, + // might be a different query + query: watchQueryOptions.query, + resultData: Object.assign(resultData, { + // We need to modify the previous `resultData` object as we rely on the + // object reference in other places + previousData: resultData.current?.data || resultData.previousData, + current: undefined, + }), + }); + } - if (client !== state.client || query !== state.query) { + if (client !== internalState.client || query !== internalState.query) { // If the client or query have changed, we need to create a new InternalState. // This will trigger a re-render with the new state, but it will also continue // to run the current render function to completion. // Since we sometimes trigger some side-effects in the render function, we // re-assign `state` to the new state to ensure that those side-effects are // triggered with the new state. - updateState((state = createInternalState(state))); + const newInternalState = createInternalState(internalState); + updateInternalState(newInternalState); + return [newInternalState, onQueryExecuted] as const; } - return state; + return [internalState, onQueryExecuted] as const; } -class InternalState { - constructor( - public readonly client: ReturnType, - public readonly query: DocumentNode | TypedDocumentNode, - previous?: InternalState - ) { - verifyDocumentType(query, DocumentType.Query); +export function useQueryInternals< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + query: DocumentNode | TypedDocumentNode, + options: QueryHookOptions, NoInfer> +) { + const client = useApolloClient(options.client); + + const renderPromises = React.useContext(getApolloContext()).renderPromises; + const isSyncSSR = !!renderPromises; + const disableNetworkFetches = client.disableNetworkFetches; + const ssrAllowed = options.ssr !== false && !options.skip; + const partialRefetch = options.partialRefetch; + + const makeWatchQueryOptions = createMakeWatchQueryOptions( + client, + query, + options, + isSyncSSR + ); - // Reuse previousData from previous InternalState (if any) to provide - // continuity of previousData even if/when the query or client changes. - const previousResult = previous && previous.result; - const previousData = previousResult && previousResult.data; - if (previousData) { - this.previousData = previousData; - } - } + const [{ observable, resultData }, onQueryExecuted] = useInternalState( + client, + query, + options, + renderPromises, + makeWatchQueryOptions + ); - /** - * Forces an update using local component state. - * As this is not batched with `useSyncExternalStore` updates, - * this is only used as a fallback if the `useSyncExternalStore` "force update" - * method is not registered at the moment. - * See https://github.com/facebook/react/issues/25191 - * */ - forceUpdateState() { - // Replaced (in useInternalState) with a method that triggers an update. - invariant.warn( - "Calling default no-op implementation of InternalState#forceUpdate" - ); - } + const watchQueryOptions: Readonly> = + makeWatchQueryOptions(observable); - /** - * Will be overwritten by the `useSyncExternalStore` "force update" method - * whenever it is available and reset to `forceUpdateState` when it isn't. - */ - forceUpdate = () => this.forceUpdateState(); + useResubscribeIfNecessary( + resultData, // might get mutated during render + observable, // might get mutated during render + client, + options, + watchQueryOptions + ); - executeQuery( - options: QueryHookOptions & { - query?: DocumentNode; - } - ) { - if (options.query) { - Object.assign(this, { query: options.query }); - } + const obsQueryFields = React.useMemo< + Omit, "variables"> + >(() => bindObservableMethods(observable), [observable]); + + useHandleSkip( + resultData, // might get mutated during render + observable, + client, + options, + watchQueryOptions, + disableNetworkFetches, + isSyncSSR + ); - this.watchQueryOptions = this.createWatchQueryOptions( - (this.queryHookOptions = options) - ); + useRegisterSSRObservable(observable, renderPromises, ssrAllowed); + + const result = useObservableSubscriptionResult( + resultData, + observable, + client, + disableNetworkFetches, + partialRefetch, + isSyncSSR, + { + onCompleted: options.onCompleted || noop, + onError: options.onError || noop, + } + ); - const concast = this.observable.reobserveAsConcast( - this.getObsQueryOptions() - ); + return { + result, + obsQueryFields, + observable, + resultData, + client, + onQueryExecuted, + }; +} - // Make sure getCurrentResult returns a fresh ApolloQueryResult, - // but save the current data as this.previousData, just like setResult - // usually does. - this.previousData = this.result?.data || this.previousData; - this.result = void 0; - this.forceUpdate(); - - return new Promise>((resolve) => { - let result: ApolloQueryResult; - - // 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(this.toQueryResult(this.observable.getCurrentResult())); - }, - complete: () => { - resolve(this.toQueryResult(result)); - }, - }); - }); +function useObservableSubscriptionResult< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + resultData: InternalResult, + observable: ObservableQuery, + client: ApolloClient, + disableNetworkFetches: boolean, + partialRefetch: boolean | undefined, + skipSubscribing: boolean, + callbacks: { + onCompleted: (data: TData) => void; + onError: (error: ApolloError) => void; } +) { + const callbackRef = React.useRef>(callbacks); + React.useEffect(() => { + // Make sure state.onCompleted and state.onError always reflect the latest + // options.onCompleted and options.onError callbacks provided to useQuery, + // since those functions are often recreated every time useQuery is called. + // Like the forceUpdate method, the versions of these methods inherited from + // InternalState.prototype are empty no-ops, but we can override them on the + // base state object (without modifying the prototype). + callbackRef.current = callbacks; + }); - // Methods beginning with use- should be called according to the standard - // rules of React hooks: only at the top level of the calling function, and - // without any dynamic conditional logic. - useQuery(options: QueryHookOptions) { - // The renderPromises field gets initialized here in the useQuery method, at - // the beginning of everything (for a given component rendering, at least), - // so we can safely use this.renderPromises in other/later InternalState - // methods without worrying it might be uninitialized. Even after - // initialization, this.renderPromises is usually undefined (unless SSR is - // happening), but that's fine as long as it has been initialized that way, - // rather than left uninitialized. - // eslint-disable-next-line react-hooks/rules-of-hooks - this.renderPromises = React.useContext(getApolloContext()).renderPromises; - - this.useOptions(options); - - const obsQuery = this.useObservableQuery(); - - // eslint-disable-next-line react-hooks/rules-of-hooks - const result = useSyncExternalStore( - // eslint-disable-next-line react-hooks/rules-of-hooks - React.useCallback( - (handleStoreChange) => { - if (this.renderPromises) { - return () => {}; + return useSyncExternalStore( + React.useCallback( + (handleStoreChange) => { + // reference `disableNetworkFetches` here to ensure that the rules of hooks + // keep it as a dependency of this effect, even though it's not used + disableNetworkFetches; + + if (skipSubscribing) { + return () => {}; + } + + const onNext = () => { + const previousResult = resultData.current; + // We use `getCurrentResult()` instead of the onNext argument because + // the values differ slightly. Specifically, loading results will have + // an empty object for data instead of `undefined` for some reason. + const result = observable.getCurrentResult(); + // Make sure we're not attempting to re-render similar results + if ( + previousResult && + previousResult.loading === result.loading && + previousResult.networkStatus === result.networkStatus && + equal(previousResult.data, result.data) + ) { + return; } - this.forceUpdate = handleStoreChange; - - const onNext = () => { - const previousResult = this.result; - // We use `getCurrentResult()` instead of the onNext argument because - // the values differ slightly. Specifically, loading results will have - // an empty object for data instead of `undefined` for some reason. - const result = obsQuery.getCurrentResult(); - // Make sure we're not attempting to re-render similar results - if ( - previousResult && - previousResult.loading === result.loading && - previousResult.networkStatus === result.networkStatus && - equal(previousResult.data, result.data) - ) { - return; - } - - this.setResult(result); - }; - - const onError = (error: Error) => { - subscription.unsubscribe(); - subscription = obsQuery.resubscribeAfterError(onNext, onError); - - if (!hasOwnProperty.call(error, "graphQLErrors")) { - // The error is not a GraphQL error - throw error; - } - - const previousResult = this.result; - if ( - !previousResult || - (previousResult && previousResult.loading) || - !equal(error, previousResult.error) - ) { - this.setResult({ + setResult( + result, + resultData, + observable, + client, + partialRefetch, + handleStoreChange, + callbackRef.current + ); + }; + + const onError = (error: Error) => { + subscription.current.unsubscribe(); + subscription.current = observable.resubscribeAfterError( + onNext, + onError + ); + + if (!hasOwnProperty.call(error, "graphQLErrors")) { + // The error is not a GraphQL error + throw error; + } + + const previousResult = resultData.current; + if ( + !previousResult || + (previousResult && previousResult.loading) || + !equal(error, previousResult.error) + ) { + setResult( + { data: (previousResult && previousResult.data) as TData, error: error as ApolloError, loading: false, networkStatus: NetworkStatus.error, - }); - } - }; - - let subscription = obsQuery.subscribe(onNext, onError); - - // Do the "unsubscribe" with a short delay. - // This way, an existing subscription can be reused without an additional - // request if "unsubscribe" and "resubscribe" to the same ObservableQuery - // happen in very fast succession. - return () => { - setTimeout(() => subscription.unsubscribe()); - this.forceUpdate = () => this.forceUpdateState(); - }; - }, - [ - // We memoize the subscribe function using useCallback and the following - // dependency keys, because the subscribe function reference is all that - // useSyncExternalStore uses internally as a dependency key for the - // useEffect ultimately responsible for the subscription, so we are - // effectively passing this dependency array to that useEffect buried - // inside useSyncExternalStore, as desired. - obsQuery, - // eslint-disable-next-line react-hooks/exhaustive-deps - this.renderPromises, - // eslint-disable-next-line react-hooks/exhaustive-deps - this.client.disableNetworkFetches, - ] + }, + resultData, + observable, + client, + partialRefetch, + handleStoreChange, + callbackRef.current + ); + } + }; + + // TODO evaluate if we keep this in + // React Compiler cannot handle scoped `let` access, but a mutable object + // like this is fine. + // was: + // let subscription = observable.subscribe(onNext, onError); + const subscription = { current: observable.subscribe(onNext, onError) }; + + // Do the "unsubscribe" with a short delay. + // This way, an existing subscription can be reused without an additional + // request if "unsubscribe" and "resubscribe" to the same ObservableQuery + // happen in very fast succession. + return () => { + setTimeout(() => subscription.current.unsubscribe()); + }; + }, + + [ + disableNetworkFetches, + skipSubscribing, + observable, + resultData, + partialRefetch, + client, + ] + ), + () => + getCurrentResult( + resultData, + observable, + callbackRef.current, + partialRefetch, + client ), + () => + getCurrentResult( + resultData, + observable, + callbackRef.current, + partialRefetch, + client + ) + ); +} - () => this.getCurrentResult(), - () => this.getCurrentResult() - ); - - // TODO Remove this method when we remove support for options.partialRefetch. - this.unsafeHandlePartialRefetch(result); +function useRegisterSSRObservable( + observable: ObsQueryWithMeta, + renderPromises: RenderPromises | undefined, + ssrAllowed: boolean +) { + if (renderPromises && ssrAllowed) { + renderPromises.registerSSRObservable(observable); - return this.toQueryResult(result); + if (observable.getCurrentResult().loading) { + // TODO: This is a legacy API which could probably be cleaned up + renderPromises.addObservableQueryPromise(observable); + } } +} - // These members (except for renderPromises) are all populated by the - // useOptions method, which is called unconditionally at the beginning of the - // useQuery method, so we can safely use these members in other/later methods - // without worrying they might be uninitialized. - private renderPromises: ApolloContextValue["renderPromises"]; - private queryHookOptions!: QueryHookOptions; - private watchQueryOptions!: WatchQueryOptions; - - private useOptions(options: QueryHookOptions) { - const watchQueryOptions = this.createWatchQueryOptions( - (this.queryHookOptions = options) +function useHandleSkip< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + /** this hook will mutate properties on `resultData` */ + resultData: InternalResult, + observable: ObsQueryWithMeta, + client: ApolloClient, + options: QueryHookOptions, NoInfer>, + watchQueryOptions: Readonly>, + disableNetworkFetches: boolean, + isSyncSSR: boolean +) { + if ( + (isSyncSSR || disableNetworkFetches) && + options.ssr === false && + !options.skip + ) { + // If SSR has been explicitly disabled, and this function has been called + // on the server side, return the default loading state. + resultData.current = toQueryResult( + ssrDisabledResult, + resultData.previousData, + observable, + client ); - - // Update this.watchQueryOptions, but only when they have changed, which - // allows us to depend on the referential stability of - // this.watchQueryOptions elsewhere. - const currentWatchQueryOptions = this.watchQueryOptions; - - if (!equal(watchQueryOptions, currentWatchQueryOptions)) { - this.watchQueryOptions = watchQueryOptions; - - if (currentWatchQueryOptions && this.observable) { - // Though it might be tempting to postpone this reobserve call to the - // useEffect block, we need getCurrentResult to return an appropriate - // loading:true result synchronously (later within the same call to - // useQuery). Since we already have this.observable here (not true for - // the very first call to useQuery), we are not initiating any new - // subscriptions, though it does feel less than ideal that reobserve - // (potentially) kicks off a network request (for example, when the - // variables have changed), which is technically a side-effect. - this.observable.reobserve(this.getObsQueryOptions()); - - // Make sure getCurrentResult returns a fresh ApolloQueryResult, - // but save the current data as this.previousData, just like setResult - // usually does. - this.previousData = this.result?.data || this.previousData; - this.result = void 0; - } - } - - // Make sure state.onCompleted and state.onError always reflect the latest - // options.onCompleted and options.onError callbacks provided to useQuery, - // since those functions are often recreated every time useQuery is called. - // Like the forceUpdate method, the versions of these methods inherited from - // InternalState.prototype are empty no-ops, but we can override them on the - // base state object (without modifying the prototype). - this.onCompleted = - options.onCompleted || InternalState.prototype.onCompleted; - this.onError = options.onError || InternalState.prototype.onError; - - if ( - (this.renderPromises || this.client.disableNetworkFetches) && - this.queryHookOptions.ssr === false && - !this.queryHookOptions.skip - ) { - // If SSR has been explicitly disabled, and this function has been called - // on the server side, return the default loading state. - this.result = this.ssrDisabledResult; - } else if ( - this.queryHookOptions.skip || - this.watchQueryOptions.fetchPolicy === "standby" - ) { - // When skipping a query (ie. we're not querying for data but still want to - // render children), make sure the `data` is cleared out and `loading` is - // set to `false` (since we aren't loading anything). - // - // NOTE: We no longer think this is the correct behavior. Skipping should - // not automatically set `data` to `undefined`, but instead leave the - // previous data in place. In other words, skipping should not mandate that - // previously received data is all of a sudden removed. Unfortunately, - // changing this is breaking, so we'll have to wait until Apollo Client 4.0 - // to address this. - this.result = this.skipStandbyResult; - } else if ( - this.result === this.ssrDisabledResult || - this.result === this.skipStandbyResult - ) { - this.result = void 0; - } + } else if (options.skip || watchQueryOptions.fetchPolicy === "standby") { + // When skipping a query (ie. we're not querying for data but still want to + // render children), make sure the `data` is cleared out and `loading` is + // set to `false` (since we aren't loading anything). + // + // NOTE: We no longer think this is the correct behavior. Skipping should + // not automatically set `data` to `undefined`, but instead leave the + // previous data in place. In other words, skipping should not mandate that + // previously received data is all of a sudden removed. Unfortunately, + // changing this is breaking, so we'll have to wait until Apollo Client 4.0 + // to address this. + resultData.current = toQueryResult( + skipStandbyResult, + resultData.previousData, + observable, + client + ); + } else if ( + // reset result if the last render was skipping for some reason, + // but this render isn't skipping anymore + resultData.current && + (resultData.current[originalResult] === ssrDisabledResult || + resultData.current[originalResult] === skipStandbyResult) + ) { + resultData.current = void 0; } +} - private getObsQueryOptions(): WatchQueryOptions { - const toMerge: Array>> = []; - - const globalDefaults = this.client.defaultOptions.watchQuery; - if (globalDefaults) toMerge.push(globalDefaults); - - if (this.queryHookOptions.defaultOptions) { - toMerge.push(this.queryHookOptions.defaultOptions); - } - - // We use compact rather than mergeOptions for this part of the merge, - // because we want watchQueryOptions.variables (if defined) to replace - // this.observable.options.variables whole. This replacement allows - // removing variables by removing them from the variables input to - // useQuery. If the variables were always merged together (rather than - // replaced), there would be no way to remove existing variables. - // However, the variables from options.defaultOptions and globalDefaults - // (if provided) should be merged, to ensure individual defaulted - // variables always have values, if not otherwise defined in - // observable.options or watchQueryOptions. - toMerge.push( - compact( - this.observable && this.observable.options, - this.watchQueryOptions - ) +// this hook is not compatible with any rules of React, and there's no good way to rewrite it. +// it should stay a separate hook that will not be optimized by the compiler +function useResubscribeIfNecessary< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + /** this hook will mutate properties on `resultData` */ + resultData: InternalResult, + /** this hook will mutate properties on `observable` */ + observable: ObsQueryWithMeta, + client: ApolloClient, + options: QueryHookOptions, NoInfer>, + watchQueryOptions: Readonly> +) { + if ( + observable[lastWatchOptions] && + !equal(observable[lastWatchOptions], watchQueryOptions) + ) { + // Though it might be tempting to postpone this reobserve call to the + // useEffect block, we need getCurrentResult to return an appropriate + // loading:true result synchronously (later within the same call to + // useQuery). Since we already have this.observable here (not true for + // the very first call to useQuery), we are not initiating any new + // subscriptions, though it does feel less than ideal that reobserve + // (potentially) kicks off a network request (for example, when the + // variables have changed), which is technically a side-effect. + observable.reobserve( + getObsQueryOptions(observable, client, options, watchQueryOptions) ); - return toMerge.reduce(mergeOptions) as WatchQueryOptions; + // Make sure getCurrentResult returns a fresh ApolloQueryResult, + // but save the current data as this.previousData, just like setResult + // usually does. + resultData.previousData = + resultData.current?.data || resultData.previousData; + resultData.current = void 0; } + observable[lastWatchOptions] = watchQueryOptions; +} - private ssrDisabledResult = maybeDeepFreeze({ - loading: true, - data: void 0 as unknown as TData, - error: void 0, - networkStatus: NetworkStatus.loading, - }); - - private skipStandbyResult = maybeDeepFreeze({ - loading: false, - data: void 0 as unknown as TData, - error: void 0, - networkStatus: NetworkStatus.ready, - }); - - // A function to massage options before passing them to ObservableQuery. - private createWatchQueryOptions({ +/* + * A function to massage options before passing them to ObservableQuery. + * This is two-step curried because we want to reuse the `make` function, + * but the `observable` might differ between calls to `make`. + */ +export function createMakeWatchQueryOptions< + TData = any, + TVariables extends OperationVariables = OperationVariables, +>( + client: ApolloClient, + query: DocumentNode | TypedDocumentNode, + { skip, ssr, onCompleted, @@ -463,17 +603,19 @@ class InternalState { // makes otherOptions almost a WatchQueryOptions object, except for the // query property that we add below. ...otherOptions - }: QueryHookOptions = {}): WatchQueryOptions< - TVariables, - TData - > { + }: QueryHookOptions = {}, + isSyncSSR: boolean +) { + return ( + observable?: ObservableQuery + ): WatchQueryOptions => { // This Object.assign is safe because otherOptions is a fresh ...rest object // that did not exist until just now, so modifications are still allowed. const watchQueryOptions: WatchQueryOptions = - Object.assign(otherOptions, { query: this.query }); + Object.assign(otherOptions, { query }); if ( - this.renderPromises && + isSyncSSR && (watchQueryOptions.fetchPolicy === "network-only" || watchQueryOptions.fetchPolicy === "cache-and-network") ) { @@ -487,208 +629,246 @@ class InternalState { } if (skip) { - const { - fetchPolicy = this.getDefaultFetchPolicy(), - initialFetchPolicy = fetchPolicy, - } = watchQueryOptions; - // When skipping, we set watchQueryOptions.fetchPolicy initially to // "standby", but we also need/want to preserve the initial non-standby // fetchPolicy that would have been used if not skipping. - Object.assign(watchQueryOptions, { - initialFetchPolicy, - fetchPolicy: "standby", - }); + watchQueryOptions.initialFetchPolicy = + watchQueryOptions.initialFetchPolicy || + watchQueryOptions.fetchPolicy || + getDefaultFetchPolicy(defaultOptions, client.defaultOptions); + watchQueryOptions.fetchPolicy = "standby"; } else if (!watchQueryOptions.fetchPolicy) { watchQueryOptions.fetchPolicy = - this.observable?.options.initialFetchPolicy || - this.getDefaultFetchPolicy(); + observable?.options.initialFetchPolicy || + getDefaultFetchPolicy(defaultOptions, client.defaultOptions); } return watchQueryOptions; - } + }; +} - getDefaultFetchPolicy(): WatchQueryFetchPolicy { - return ( - this.queryHookOptions.defaultOptions?.fetchPolicy || - this.client.defaultOptions.watchQuery?.fetchPolicy || - "cache-first" - ); +export function getObsQueryOptions< + TData, + TVariables extends OperationVariables, +>( + observable: ObservableQuery | undefined, + client: ApolloClient, + queryHookOptions: QueryHookOptions, + watchQueryOptions: Partial> +): WatchQueryOptions { + const toMerge: Array>> = []; + + const globalDefaults = client.defaultOptions.watchQuery; + if (globalDefaults) toMerge.push(globalDefaults); + + if (queryHookOptions.defaultOptions) { + toMerge.push(queryHookOptions.defaultOptions); } - // Defining these methods as no-ops on the prototype allows us to call - // state.onCompleted and/or state.onError without worrying about whether a - // callback was provided. - private onCompleted(data: TData) {} - private onError(error: ApolloError) {} - - private observable!: ObservableQuery; - public obsQueryFields!: Omit< - ObservableQueryFields, - "variables" - >; - - private useObservableQuery() { - // See if there is an existing observable that was used to fetch the same - // data and if so, use it instead since it will contain the proper queryId - // to fetch the result set. This is used during SSR. - const obsQuery = (this.observable = - (this.renderPromises && - this.renderPromises.getSSRObservable(this.watchQueryOptions)) || - this.observable || // Reuse this.observable if possible (and not SSR) - this.client.watchQuery(this.getObsQueryOptions())); - - // eslint-disable-next-line react-hooks/rules-of-hooks - this.obsQueryFields = React.useMemo( - () => ({ - refetch: obsQuery.refetch.bind(obsQuery), - reobserve: obsQuery.reobserve.bind(obsQuery), - fetchMore: obsQuery.fetchMore.bind(obsQuery), - updateQuery: obsQuery.updateQuery.bind(obsQuery), - startPolling: obsQuery.startPolling.bind(obsQuery), - stopPolling: obsQuery.stopPolling.bind(obsQuery), - subscribeToMore: obsQuery.subscribeToMore.bind(obsQuery), - }), - [obsQuery] - ); - - const ssrAllowed = !( - this.queryHookOptions.ssr === false || this.queryHookOptions.skip - ); - - if (this.renderPromises && ssrAllowed) { - this.renderPromises.registerSSRObservable(obsQuery); - - if (obsQuery.getCurrentResult().loading) { - // TODO: This is a legacy API which could probably be cleaned up - this.renderPromises.addObservableQueryPromise(obsQuery); - } - } + // We use compact rather than mergeOptions for this part of the merge, + // because we want watchQueryOptions.variables (if defined) to replace + // this.observable.options.variables whole. This replacement allows + // removing variables by removing them from the variables input to + // useQuery. If the variables were always merged together (rather than + // replaced), there would be no way to remove existing variables. + // However, the variables from options.defaultOptions and globalDefaults + // (if provided) should be merged, to ensure individual defaulted + // variables always have values, if not otherwise defined in + // observable.options or watchQueryOptions. + toMerge.push(compact(observable && observable.options, watchQueryOptions)); + + return toMerge.reduce(mergeOptions) as WatchQueryOptions; +} - return obsQuery; +function setResult( + nextResult: ApolloQueryResult, + resultData: InternalResult, + observable: ObservableQuery, + client: ApolloClient, + partialRefetch: boolean | undefined, + forceUpdate: () => void, + callbacks: Callbacks +) { + const previousResult = resultData.current; + if (previousResult && previousResult.data) { + resultData.previousData = previousResult.data; } + resultData.current = toQueryResult( + unsafeHandlePartialRefetch(nextResult, observable, partialRefetch), + resultData.previousData, + observable, + client + ); + // Calling state.setResult always triggers an update, though some call sites + // perform additional equality checks before committing to an update. + forceUpdate(); + handleErrorOrCompleted( + nextResult, + previousResult?.[originalResult], + callbacks + ); +} - // These members are populated by getCurrentResult and setResult, and it's - // okay/normal for them to be initially undefined. - private result: undefined | ApolloQueryResult; - private previousData: undefined | TData; - - private setResult(nextResult: ApolloQueryResult) { - const previousResult = this.result; - if (previousResult && previousResult.data) { - this.previousData = previousResult.data; - } - this.result = nextResult; - // Calling state.setResult always triggers an update, though some call sites - // perform additional equality checks before committing to an update. - this.forceUpdate(); - this.handleErrorOrCompleted(nextResult, previousResult); +function handleErrorOrCompleted( + result: ApolloQueryResult, + previousResult: ApolloQueryResult | undefined, + callbacks: Callbacks +) { + if (!result.loading) { + const error = toApolloError(result); + + // wait a tick in case we are in the middle of rendering a component + Promise.resolve() + .then(() => { + if (error) { + callbacks.onError(error); + } else if ( + result.data && + previousResult?.networkStatus !== result.networkStatus && + result.networkStatus === NetworkStatus.ready + ) { + callbacks.onCompleted(result.data); + } + }) + .catch((error) => { + invariant.warn(error); + }); } +} - private handleErrorOrCompleted( - result: ApolloQueryResult, - previousResult?: ApolloQueryResult - ) { - if (!result.loading) { - const error = this.toApolloError(result); - - // wait a tick in case we are in the middle of rendering a component - Promise.resolve() - .then(() => { - if (error) { - this.onError(error); - } else if ( - result.data && - previousResult?.networkStatus !== result.networkStatus && - result.networkStatus === NetworkStatus.ready - ) { - this.onCompleted(result.data); - } - }) - .catch((error) => { - invariant.warn(error); - }); - } +function getCurrentResult( + resultData: InternalResult, + observable: ObservableQuery, + callbacks: Callbacks, + partialRefetch: boolean | undefined, + client: ApolloClient +): InternalQueryResult { + // Using this.result as a cache ensures getCurrentResult continues returning + // the same (===) result object, unless state.setResult has been called, or + // we're doing server rendering and therefore override the result below. + if (!resultData.current) { + // WARNING: SIDE-EFFECTS IN THE RENDER FUNCTION + // this could call unsafeHandlePartialRefetch + setResult( + observable.getCurrentResult(), + resultData, + observable, + client, + partialRefetch, + () => {}, + callbacks + ); } + return resultData.current!; +} - private toApolloError( - result: ApolloQueryResult - ): ApolloError | undefined { - return isNonEmptyArray(result.errors) ? - new ApolloError({ graphQLErrors: result.errors }) - : result.error; - } +export function getDefaultFetchPolicy< + TData, + TVariables extends OperationVariables, +>( + queryHookDefaultOptions?: Partial>, + clientDefaultOptions?: DefaultOptions +): WatchQueryFetchPolicy { + return ( + queryHookDefaultOptions?.fetchPolicy || + clientDefaultOptions?.watchQuery?.fetchPolicy || + "cache-first" + ); +} - private getCurrentResult(): ApolloQueryResult { - // Using this.result as a cache ensures getCurrentResult continues returning - // the same (===) result object, unless state.setResult has been called, or - // we're doing server rendering and therefore override the result below. - if (!this.result) { - this.handleErrorOrCompleted( - (this.result = this.observable.getCurrentResult()) - ); - } - return this.result; - } +function toApolloError( + result: ApolloQueryResult +): ApolloError | undefined { + return isNonEmptyArray(result.errors) ? + new ApolloError({ graphQLErrors: result.errors }) + : result.error; +} - // This cache allows the referential stability of this.result (as returned by - // getCurrentResult) to translate into referential stability of the resulting - // QueryResult object returned by toQueryResult. - private toQueryResultCache = new (canUseWeakMap ? WeakMap : Map)< - ApolloQueryResult, - QueryResult - >(); - - toQueryResult( - result: ApolloQueryResult - ): QueryResult { - let queryResult = this.toQueryResultCache.get(result); - if (queryResult) return queryResult; - - const { data, partial, ...resultWithoutPartial } = result; - this.toQueryResultCache.set( - result, - (queryResult = { - data, // Ensure always defined, even if result.data is missing. - ...resultWithoutPartial, - ...this.obsQueryFields, - client: this.client, - observable: this.observable, - variables: this.observable.variables, - called: !this.queryHookOptions.skip, - previousData: this.previousData, - }) - ); +export function toQueryResult( + result: ApolloQueryResult, + previousData: TData | undefined, + observable: ObservableQuery, + client: ApolloClient +): InternalQueryResult { + const { data, partial, ...resultWithoutPartial } = result; + const queryResult: InternalQueryResult = { + data, // Ensure always defined, even if result.data is missing. + ...resultWithoutPartial, + client: client, + observable: observable, + variables: observable.variables, + called: result !== ssrDisabledResult && result !== skipStandbyResult, + previousData, + } satisfies Omit< + InternalQueryResult, + typeof originalResult + > as InternalQueryResult; + // non-enumerable property to hold the original result, for referential equality checks + Object.defineProperty(queryResult, originalResult, { value: result }); + + if (!queryResult.error && isNonEmptyArray(result.errors)) { + // Until a set naming convention for networkError and graphQLErrors is + // decided upon, we map errors (graphQLErrors) to the error options. + // TODO: Is it possible for both result.error and result.errors to be + // defined here? + queryResult.error = new ApolloError({ graphQLErrors: result.errors }); + } - if (!queryResult.error && isNonEmptyArray(result.errors)) { - // Until a set naming convention for networkError and graphQLErrors is - // decided upon, we map errors (graphQLErrors) to the error options. - // TODO: Is it possible for both result.error and result.errors to be - // defined here? - queryResult.error = new ApolloError({ graphQLErrors: result.errors }); - } + return queryResult; +} - return queryResult; +function unsafeHandlePartialRefetch< + TData, + TVariables extends OperationVariables, +>( + result: ApolloQueryResult, + observable: ObservableQuery, + partialRefetch: boolean | undefined +): ApolloQueryResult { + // TODO: This code should be removed when the partialRefetch option is + // removed. I was unable to get this hook to behave reasonably in certain + // edge cases when this block was put in an effect. + if ( + result.partial && + partialRefetch && + !result.loading && + (!result.data || Object.keys(result.data).length === 0) && + observable.options.fetchPolicy !== "cache-only" + ) { + observable.refetch(); + return { + ...result, + loading: true, + networkStatus: NetworkStatus.refetch, + }; } + return result; +} - private unsafeHandlePartialRefetch(result: ApolloQueryResult) { - // WARNING: SIDE-EFFECTS IN THE RENDER FUNCTION - // - // TODO: This code should be removed when the partialRefetch option is - // removed. I was unable to get this hook to behave reasonably in certain - // edge cases when this block was put in an effect. - if ( - result.partial && - this.queryHookOptions.partialRefetch && - !result.loading && - (!result.data || Object.keys(result.data).length === 0) && - this.observable.options.fetchPolicy !== "cache-only" - ) { - Object.assign(result, { - loading: true, - networkStatus: NetworkStatus.refetch, - }); - this.observable.refetch(); - } - } +const ssrDisabledResult = maybeDeepFreeze({ + loading: true, + data: void 0 as any, + error: void 0, + networkStatus: NetworkStatus.loading, +}); + +const skipStandbyResult = maybeDeepFreeze({ + loading: false, + data: void 0 as any, + error: void 0, + networkStatus: NetworkStatus.ready, +}); + +function bindObservableMethods( + observable: ObservableQuery +) { + return { + refetch: observable.refetch.bind(observable), + reobserve: observable.reobserve.bind(observable), + fetchMore: observable.fetchMore.bind(observable), + updateQuery: observable.updateQuery.bind(observable), + startPolling: observable.startPolling.bind(observable), + stopPolling: observable.stopPolling.bind(observable), + subscribeToMore: observable.subscribeToMore.bind(observable), + }; }