Skip to content

Instantly share code, notes, and snippets.

@KATT
Created July 3, 2020 13:20
Show Gist options
  • Save KATT/f0bfb7affc01bd35295bef17cfb01e8d to your computer and use it in GitHub Desktop.
Save KATT/f0bfb7affc01bd35295bef17cfb01e8d to your computer and use it in GitHub Desktop.
// 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