Created
July 3, 2020 13:20
-
-
Save KATT/f0bfb7affc01bd35295bef17cfb01e8d to your computer and use it in GitHub Desktop.
useCacheableSubscription from https://github.com/apollographql/react-apollo/issues/3735#issuecomment-562915341
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://github.com/apollographql/react-apollo/issues/3735#issuecomment-562915341 | |
import ApolloClient, { ApolloError } from 'apollo-client'; | |
import { DocumentNode } from 'graphql'; | |
import { useEffect, useState } from 'react'; | |
import { useApolloClient } from '@apollo/react-hooks'; | |
export interface HookOptions<TData extends object, TVariables> { | |
variables?: TVariables; | |
mergeIntoCache?: ( | |
context: {}, | |
cachedData: Readonly<TData> | undefined, | |
newData: Readonly<TData> | undefined, | |
) => Readonly<TData> | undefined; | |
skip?: boolean; | |
} | |
interface HookResult<TData extends object> { | |
loading: boolean; | |
// Other Apollo hooks return undefined too in case no error/data is known. | |
data: TData | undefined; | |
error: ApolloError | undefined; | |
} | |
/** | |
* A combination of subscription and query. | |
* - Like a subscription, it listens for updates from server side continuously. | |
* - Like a query, it uses Apollo cache and triggers re-rendering if the cache | |
* is optimistically updated. | |
* - When using this hook, the server-side subscription often instantly emits | |
* the initial results and then continues watching for changes. | |
* | |
* See details of why we need this here: | |
* https://github.com/apollographql/react-apollo/issues/3735 | |
*/ | |
export function useCacheableSubscription<TData extends object, TVariables>( | |
subDoc: DocumentNode, | |
{ variables, mergeIntoCache, skip }: HookOptions<TData, TVariables> = {}, | |
): HookResult<TData> { | |
const queryDoc = subscriptionDocToQuery(subDoc); | |
const variablesStr = JSON.stringify(variables); | |
const client = useApolloClient(); | |
const [data, setData] = useState<TData | undefined>(); | |
const [apolloError, setApolloError] = useState<ApolloError | undefined>(); | |
useEffect(() => { | |
if (skip) { | |
return; | |
} | |
// To make eslint happy; variables are small, so this is quick. | |
const variables = JSON.parse(variablesStr) || undefined; | |
let mounted = true; | |
const unsubscribe = cacheableSubscription( | |
client, | |
subDoc, | |
{ variables, mergeIntoCache }, | |
(data) => (mounted ? setData(data) : null), | |
(error) => (mounted ? setApolloError(error) : null), | |
); | |
return () => { | |
mounted = false; | |
unsubscribe(); | |
}; | |
}, [client, subDoc, variablesStr, mergeIntoCache, skip]); | |
return { | |
loading: !skip && !data && !apolloError, | |
data: skip ? undefined : data ?? readQuery(client, queryDoc, variables), | |
error: apolloError, | |
}; | |
} | |
/** | |
* The engine for useCacheableSubscription(). | |
* - Can be used when hooks are not available (e.g. in preloader). | |
* - Calls setData() and setApolloError() when appropriate (this is a way | |
* simpler abstraction than returning an Observable). | |
* - Returns a callback which unsubscribes from everything if called. | |
*/ | |
export function cacheableSubscription<TData extends object, TVariables>( | |
client: ApolloClient<any>, | |
subDoc: DocumentNode, | |
{ variables, mergeIntoCache }: HookOptions<TData, TVariables>, | |
setData: (data: TData) => void, | |
setApolloError: (error: ApolloError) => void, | |
) { | |
const queryDoc = subscriptionDocToQuery(subDoc); | |
// Watch our "fake virtual" query which we manually update with | |
// writeQuery(). Since it watches the cache only, there can be no errors. | |
const watchQuery = client | |
.watchQuery<TData>({ | |
query: queryDoc, | |
variables, | |
fetchPolicy: 'cache-only', | |
}) | |
.subscribe(({ data }) => { | |
// watchQuery triggers a few times with different networkStatus, it | |
// doesn't make sense in our use-case since it's a "fake virtual" query | |
// which reads from the cache only. So we trigger re-rendering only when | |
// the data is available. | |
if ( | |
data && | |
Object.keys(data).length > 0 && | |
readQuery(client, queryDoc, variables) // ensure data can be fulfilled | |
) { | |
setData(data); | |
} | |
}); | |
// Run a real GraphQL subscription and writeQuery() to our "fake virtual" | |
// query cache slot every time a new data is available. | |
const mergeContext = {}; | |
const subscription = client | |
.subscribe<TData>({ query: subDoc, variables }) | |
.subscribe( | |
({ data, errors }) => { | |
if (mergeIntoCache && data) { | |
const cachedData = readQuery<TData>(client, queryDoc, variables); | |
data = mergeIntoCache(mergeContext, cachedData, data); | |
} | |
if (data) { | |
client.writeQuery({ query: queryDoc, variables, data }); | |
} | |
if (errors) { | |
setApolloError(new ApolloError({ graphQLErrors: errors })); | |
} | |
}, | |
(error) => { | |
setApolloError(error); | |
}, | |
(/* done */) => { | |
if (mergeIntoCache) { | |
const cachedData = readQuery<TData>(client, queryDoc, variables); | |
const data = mergeIntoCache(mergeContext, cachedData, undefined); | |
if (JSON.stringify(data) !== JSON.stringify(cachedData)) { | |
client.writeQuery({ query: queryDoc, variables, data }); | |
} | |
} | |
}, | |
); | |
return () => { | |
watchQuery.unsubscribe(); | |
subscription.unsubscribe(); | |
}; | |
} | |
/** | |
* Converts a subscription document to query document. We later use this query | |
* document as a slot in Apollo cache to be able to optimistically update | |
* subscription results. | |
*/ | |
export function subscriptionDocToQuery( | |
sub: DocumentNode & { queryDoc?: DocumentNode }, | |
): DocumentNode { | |
// Caching the query document in the original subscription document is | |
// important, because if we recreate it every time, react hooks will | |
// fall into infinite loop. | |
if (!sub.queryDoc) { | |
sub.queryDoc = { | |
...sub, | |
definitions: sub.definitions.map((def) => | |
def.kind === 'OperationDefinition' | |
? { ...def, operation: 'query' } | |
: def, | |
), | |
}; | |
} | |
return sub.queryDoc; | |
} | |
/** | |
* Reads the query results from Apollo cache. | |
*/ | |
function readQuery<TData extends object>( | |
client: ApolloClient<any>, | |
query: DocumentNode, | |
variables: any, | |
): TData | undefined { | |
try { | |
return client.readQuery<TData>({ query, variables }) ?? undefined; | |
} catch (_) { | |
// readQuery() throws in case of empty cache, it's a part of Apollo API | |
return undefined; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment