diff --git a/src/react/context/index.ts b/src/react/context/index.ts index 860b3839b46..872a07df7da 100644 --- a/src/react/context/index.ts +++ b/src/react/context/index.ts @@ -1,3 +1,7 @@ -export * from './ApolloConsumer'; -export * from './ApolloContext'; -export * from './ApolloProvider'; +export { ApolloConsumer, ApolloConsumerProps } from './ApolloConsumer'; +export { + ApolloContextValue, + getApolloContext, + getApolloContext as resetApolloContext +} from './ApolloContext'; +export { ApolloProvider, ApolloProviderProps } from './ApolloProvider'; diff --git a/src/react/hooks/useQuery.ts b/src/react/hooks/useQuery.ts index bf75ad0e323..72e0899b947 100644 --- a/src/react/hooks/useQuery.ts +++ b/src/react/hooks/useQuery.ts @@ -1,16 +1,638 @@ -import { DocumentNode } from 'graphql'; -import { TypedDocumentNode } from '@graphql-typed-document-node/core'; - -import { QueryHookOptions, QueryResult } from '../types/types'; -import { useBaseQuery } from './utils/useBaseQuery'; +import { useContext, useEffect, useReducer, useRef } from 'react'; +import { invariant } from 'ts-invariant'; +import { equal } from '@wry/equality'; import { OperationVariables } from '../../core'; +import { getApolloContext } from '../context'; +import { ApolloError } from '../../errors'; +import { + ApolloClient, + NetworkStatus, + FetchMoreQueryOptions, + SubscribeToMoreOptions, + ObservableQuery, + FetchMoreOptions, + UpdateQueryOptions, + DocumentNode, + TypedDocumentNode, +} from '../../core'; +import { + CommonOptions, + QueryDataOptions, + QueryHookOptions, + QueryResult, + QueryLazyOptions, + ObservableQueryFields, +} from '../types/types'; + +import { + ObservableSubscription +} from '../../utilities'; +import { DocumentType, parser, operationName } from '../parser'; + +import { useDeepMemo } from './utils/useDeepMemo'; +import { useAfterFastRefresh } from './utils/useAfterFastRefresh'; + +type ObservableQueryOptions = + ReturnType["prepareObservableQueryOptions"]>; + +class QueryData = any> { + public isMounted: boolean = false; + public previousOptions: CommonOptions + = {} as CommonOptions; + public context: any = {}; + public client: ApolloClient; + + private options: CommonOptions = {} as CommonOptions; + + public setOptions( + newOptions: CommonOptions, + storePrevious: boolean = false + ) { + if (storePrevious && !equal(this.options, newOptions)) { + this.previousOptions = this.options; + } + this.options = newOptions; + } + + protected unmount() { + this.isMounted = false; + } + + protected refreshClient() { + const client = + (this.options && this.options.client) || + (this.context && this.context.client); + + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ' + + 'ApolloClient instance in via options.' + ); + + let isNew = false; + if (client !== this.client) { + isNew = true; + this.client = client; + this.cleanup(); + } + return { + client: this.client as ApolloClient, + isNew + }; + } + + protected verifyDocumentType(document: DocumentNode, type: DocumentType) { + const operation = parser(document); + const requiredOperationName = operationName(type); + const usedOperationName = operationName(operation.type); + invariant( + operation.type === type, + `Running a ${requiredOperationName} requires a graphql ` + + `${requiredOperationName}, but a ${usedOperationName} was used instead.` + ); + } + public onNewData: () => void; + private currentObservable?: ObservableQuery; + private currentSubscription?: ObservableSubscription; + private lazyOptions?: QueryLazyOptions; + private previous: { + client?: ApolloClient; + query?: DocumentNode | TypedDocumentNode; + observableQueryOptions?: ObservableQueryOptions; + result?: QueryResult; + loading?: boolean; + options?: QueryDataOptions; + error?: ApolloError; + } = Object.create(null); + + constructor({ + options, + context, + onNewData + }: { + options: TOptions; + context: any; + onNewData: () => void; + }) { + this.options = options || ({} as CommonOptions); + this.context = context || {}; + this.onNewData = onNewData; + } + + public execute(): QueryResult { + this.refreshClient(); + + const { skip, query } = this.getOptions(); + if (skip || query !== this.previous.query) { + this.removeQuerySubscription(); + this.removeObservable(!skip); + this.previous.query = query; + } + + this.updateObservableQuery(); + + return this.getExecuteSsrResult() || this.getExecuteResult(); + } + + // For server-side rendering + public fetchData(): Promise | boolean { + const options = this.getOptions(); + if (options.skip || options.ssr === false) return false; + return new Promise(resolve => this.startQuerySubscription(resolve)); + } + + public afterExecute() { + this.isMounted = true; + const options = this.getOptions(); + const ssrDisabled = options.ssr === false; + if ( + this.currentObservable && + !ssrDisabled && + !this.ssrInitiated() + ) { + this.startQuerySubscription(); + } + + this.handleErrorOrCompleted(); + this.previousOptions = options; + return this.unmount.bind(this); + } + + public cleanup() { + this.removeQuerySubscription(); + this.removeObservable(true); + delete this.previous.result; + } + + public getOptions() { + const options = this.options; + if (this.lazyOptions) { + options.variables = { + ...options.variables, + ...this.lazyOptions.variables + } as TVariables; + options.context = { + ...options.context, + ...this.lazyOptions.context + }; + } + + return options; + } + + public ssrInitiated() { + return this.context && this.context.renderPromises; + } + + private getExecuteSsrResult() { + const { ssr, skip } = this.getOptions(); + const ssrDisabled = ssr === false; + const fetchDisabled = this.refreshClient().client.disableNetworkFetches; + + const ssrLoading = { + loading: true, + networkStatus: NetworkStatus.loading, + called: true, + data: undefined, + stale: false, + client: this.client, + ...this.observableQueryFields(), + } as QueryResult; + + // If SSR has been explicitly disabled, and this function has been called + // on the server side, return the default loading state. + if (ssrDisabled && (this.ssrInitiated() || fetchDisabled)) { + this.previous.result = ssrLoading; + return ssrLoading; + } + + if (this.ssrInitiated()) { + const result = this.getExecuteResult() || ssrLoading; + if (result.loading && !skip) { + this.context.renderPromises!.addQueryPromise(this, () => null); + } + return result; + } + } + + private prepareObservableQueryOptions() { + const options = this.getOptions(); + this.verifyDocumentType(options.query, DocumentType.Query); + const displayName = options.displayName || 'Query'; + + // Set the fetchPolicy to cache-first for network-only and cache-and-network + // fetches for server side renders. + if ( + this.ssrInitiated() && + (options.fetchPolicy === 'network-only' || + options.fetchPolicy === 'cache-and-network') + ) { + options.fetchPolicy = 'cache-first'; + } + + return { + ...options, + displayName, + context: options.context, + }; + } + + private initializeObservableQuery() { + // 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. + if (this.ssrInitiated()) { + this.currentObservable = this.context!.renderPromises!.getSSRObservable( + this.getOptions() + ); + } + + if (!this.currentObservable) { + const observableQueryOptions = this.prepareObservableQueryOptions(); + + this.previous.observableQueryOptions = { + ...observableQueryOptions, + children: void 0, + }; + this.currentObservable = this.refreshClient().client.watchQuery({ + ...observableQueryOptions + }); + + if (this.ssrInitiated()) { + this.context!.renderPromises!.registerSSRObservable( + this.currentObservable, + observableQueryOptions + ); + } + } + } + + private updateObservableQuery() { + // If we skipped initially, we may not have yet created the observable + if (!this.currentObservable) { + this.initializeObservableQuery(); + return; + } + + const newObservableQueryOptions = { + ...this.prepareObservableQueryOptions(), + children: void 0, + }; + + if (this.getOptions().skip) { + this.previous.observableQueryOptions = newObservableQueryOptions; + return; + } + + if ( + !equal(newObservableQueryOptions, this.previous.observableQueryOptions) + ) { + this.previous.observableQueryOptions = newObservableQueryOptions; + this.currentObservable + .setOptions(newObservableQueryOptions) + // The error will be passed to the child container, so we don't + // need to log it here. We could conceivably log something if + // an option was set. OTOH we don't log errors w/ the original + // query. See https://github.com/apollostack/react-apollo/issues/404 + .catch(() => {}); + } + } + + // Setup a subscription to watch for Apollo Client `ObservableQuery` changes. + // When new data is received, and it doesn't match the data that was used + // during the last `QueryData.execute` call (and ultimately the last query + // component render), trigger the `onNewData` callback. If not specified, + // `onNewData` will fallback to the default `QueryData.onNewData` function + // (which usually leads to a query component re-render). + private startQuerySubscription(onNewData: () => void = this.onNewData) { + if (this.currentSubscription || this.getOptions().skip) return; + + this.currentSubscription = this.currentObservable!.subscribe({ + next: ({ loading, networkStatus, data }) => { + const previousResult = this.previous.result; + + // Make sure we're not attempting to re-render similar results + if ( + previousResult && + previousResult.loading === loading && + previousResult.networkStatus === networkStatus && + equal(previousResult.data, data) + ) { + return; + } + + onNewData(); + }, + error: error => { + this.resubscribeToQuery(); + if (!error.hasOwnProperty('graphQLErrors')) throw error; + + const previousResult = this.previous.result; + if ( + (previousResult && previousResult.loading) || + !equal(error, this.previous.error) + ) { + this.previous.error = error; + onNewData(); + } + } + }); + } + + private resubscribeToQuery() { + this.removeQuerySubscription(); + + // Unfortunately, if `lastError` is set in the current + // `observableQuery` when the subscription is re-created, + // the subscription will immediately receive the error, which will + // cause it to terminate again. To avoid this, we first clear + // the last error/result from the `observableQuery` before re-starting + // the subscription, and restore it afterwards (so the subscription + // has a chance to stay open). + const { currentObservable } = this; + if (currentObservable) { + const lastError = currentObservable.getLastError(); + const lastResult = currentObservable.getLastResult(); + currentObservable.resetLastResults(); + this.startQuerySubscription(); + Object.assign(currentObservable, { + lastError, + lastResult + }); + } + } + + private getExecuteResult(): QueryResult { + let result = this.observableQueryFields() as QueryResult; + const options = this.getOptions(); + + // 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. + if (options.skip) { + result = { + ...result, + data: undefined, + error: undefined, + loading: false, + networkStatus: NetworkStatus.ready, + called: true, + }; + } else if (this.currentObservable) { + // Fetch the current result (if any) from the store. + const currentResult = this.currentObservable.getCurrentResult(); + const { data, loading, partial, networkStatus, errors } = currentResult; + let { error } = currentResult; + + // Until a set naming convention for networkError and graphQLErrors is + // decided upon, we map errors (graphQLErrors) to the error options. + if (errors && errors.length > 0) { + error = new ApolloError({ graphQLErrors: errors }); + } + + result = { + ...result, + data, + loading, + networkStatus, + error, + called: true + }; + + if (loading) { + // Fall through without modifying result... + } else if (error) { + Object.assign(result, { + data: (this.currentObservable.getLastResult() || ({} as any)) + .data + }); + } else { + const { fetchPolicy } = this.currentObservable.options; + const { partialRefetch } = options; + if ( + partialRefetch && + partial && + (!data || Object.keys(data).length === 0) && + fetchPolicy !== 'cache-only' + ) { + // When a `Query` component is mounted, and a mutation is executed + // that returns the same ID as the mounted `Query`, but has less + // fields in its result, Apollo Client's `QueryManager` returns the + // data as `undefined` since a hit can't be found in the cache. + // This can lead to application errors when the UI elements rendered by + // the original `Query` component are expecting certain data values to + // exist, and they're all of a sudden stripped away. To help avoid + // this we'll attempt to refetch the `Query` data. + Object.assign(result, { + loading: true, + networkStatus: NetworkStatus.loading + }); + result.refetch(); + return result; + } + } + } + + result.client = this.client; + // Store options as this.previousOptions. + this.setOptions(options, true); + + const previousResult = this.previous.result; + + this.previous.loading = + previousResult && previousResult.loading || false; + + // Ensure the returned result contains previousData as a separate + // property, to give developers the flexibility of leveraging outdated + // data while new data is loading from the network. Falling back to + // previousResult.previousData when previousResult.data is falsy here + // allows result.previousData to persist across multiple results. + result.previousData = previousResult && + (previousResult.data || previousResult.previousData); + + this.previous.result = result; + + // Any query errors that exist are now available in `result`, so we'll + // remove the original errors from the `ObservableQuery` query store to + // make sure they aren't re-displayed on subsequent (potentially error + // free) requests/responses. + this.currentObservable && this.currentObservable.resetQueryStoreErrors(); + + return result; + } + + private handleErrorOrCompleted() { + if (!this.currentObservable || !this.previous.result) return; + + const { data, loading, error } = this.previous.result; + + if (!loading) { + const { + query, + variables, + onCompleted, + onError, + skip + } = this.getOptions(); + + // No changes, so we won't call onError/onCompleted. + if ( + this.previousOptions && + !this.previous.loading && + equal(this.previousOptions.query, query) && + equal(this.previousOptions.variables, variables) + ) { + return; + } + + if (onCompleted && !error && !skip) { + onCompleted(data as TData); + } else if (onError && error) { + onError(error); + } + } + } + + private removeQuerySubscription() { + if (this.currentSubscription) { + this.currentSubscription.unsubscribe(); + delete this.currentSubscription; + } + } + + private removeObservable(andDelete: boolean) { + if (this.currentObservable) { + this.currentObservable["tearDownQuery"](); + if (andDelete) { + delete this.currentObservable; + } + } + } + + private obsRefetch = (variables?: Partial) => + this.currentObservable?.refetch(variables); + + private obsFetchMore = ( + fetchMoreOptions: FetchMoreQueryOptions & + FetchMoreOptions + ) => this.currentObservable!.fetchMore(fetchMoreOptions); + + private obsUpdateQuery = ( + mapFn: ( + previousQueryResult: TData, + options: UpdateQueryOptions + ) => TData + ) => this.currentObservable!.updateQuery(mapFn); + + private obsStartPolling = (pollInterval: number) => { + this.currentObservable?.startPolling(pollInterval); + }; + + private obsStopPolling = () => { + this.currentObservable?.stopPolling(); + }; + + private obsSubscribeToMore = < + TSubscriptionData = TData, + TSubscriptionVariables = TVariables + >( + options: SubscribeToMoreOptions< + TData, + TSubscriptionVariables, + TSubscriptionData + > + ) => this.currentObservable!.subscribeToMore(options); + + private observableQueryFields() { + return { + variables: this.currentObservable?.variables, + refetch: this.obsRefetch, + fetchMore: this.obsFetchMore, + updateQuery: this.obsUpdateQuery, + startPolling: this.obsStartPolling, + stopPolling: this.obsStopPolling, + subscribeToMore: this.obsSubscribeToMore + } as ObservableQueryFields; + } +} export function useQuery( query: DocumentNode | TypedDocumentNode, - options?: QueryHookOptions + options?: QueryHookOptions, ) { - return useBaseQuery(query, options, false) as QueryResult< - TData, - TVariables - >; + const context = useContext(getApolloContext()); + const client = options?.client || context.client; + invariant( + !!client, + 'Could not find "client" in the context or passed in as an option. ' + + 'Wrap the root component in an , or pass an ' + + 'ApolloClient instance in via options.' + ); + + const [tick, forceUpdate] = useReducer(x => x + 1, 0); + const updatedOptions: QueryDataOptions + = options ? { ...options, query } : { query }; + + const queryDataRef = useRef>(); + const queryData = queryDataRef.current || ( + queryDataRef.current = new QueryData({ + options: updatedOptions, + context, + onNewData() { + if (!queryData.ssrInitiated()) { + // When new data is received from the `QueryData` object, we want to + // force a re-render to make sure the new data is displayed. We can't + // force that re-render if we're already rendering however so to be + // safe we'll trigger the re-render in a microtask. In case the + // component gets unmounted before this callback fires, we re-check + // queryDataRef.current.isMounted before calling forceUpdate(). + Promise.resolve().then(() => queryDataRef.current && queryDataRef.current.isMounted && forceUpdate()); + } else { + // If we're rendering on the server side we can force an update at + // any point. + forceUpdate(); + } + } + }) + ); + + queryData.setOptions(updatedOptions); + queryData.context = context; + const result = useDeepMemo( + () => queryData.execute(), + [updatedOptions, context, tick], + ); + + const queryResult = (result as QueryResult); + + if (__DEV__) { + // ensure we run an update after refreshing so that we reinitialize + useAfterFastRefresh(forceUpdate); + } + + useEffect(() => { + return () => { + queryData.cleanup(); + // this effect can run multiple times during a fast-refresh + // so make sure we clean up the ref + queryDataRef.current = void 0; + } + }, []); + + useEffect(() => queryData.afterExecute(), [ + queryResult.loading, + queryResult.networkStatus, + queryResult.error, + queryResult.data, + ]); + + return result; }