apollo-feature-requests
apollo-feature-requests copied to clipboard
Persist fetchPolicy "state" across component mounts and un-mounts
Context
Per this issue, here is the current behavior in a React component's lifecycle when using fetchPolicy and nextFetchPolicy:
-
Component A mounts, all queries within this component tree will fire utilizing their
fetchPolicyor thefetchPolicyfromdefaultOptions.watchQuery.fetchPolicy. -
Component A unmounts for some reason, perhaps for a route change.
-
Component A re-mounts, and again, it uses its
fetchPolicy, rather thannextFetchPolicy, because the component state has been lost.
It would be desirable to opt in to preserving the fetchPolicy "state" of whether a query has already completed, such that on step 3, we utilize nextFetchPolicy instead of fetchPolicy.
Specifically, this would help with the scenario where we have:
watchQuery: {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
}
In this scenario, I want to read from persisted cache (using apollo-cache-persist) first, while also triggering a query to update any potentially stale data from disk.
Thereafter, those queries should always attempt to use cache-first, even if the component has un-mounted. I expect any updates to my cache will happen from manual cache updates or using refetchQueries following a mutation.
Current workaround
My current workaround is using my own wrapped useQuery and wrapped ApolloClient:
// This wrapper has extended "defaultOptions" to accept this argument for TypeScript.
const apolloClient = new WrappedApolloClient({
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
persistFetchPolicyState: true,
}
}
...
});
const firstFetchCompleteMap = new Map<string, boolean>();
const getNextFetchPolicy = (
lastPolicy: WatchQueryFetchPolicy = 'cache-first',
nextFetchPolicy?: WatchQueryFetchPolicy | ((lastPolicy: WatchQueryFetchPolicy) => WatchQueryFetchPolicy),
): WatchQueryFetchPolicy => {
if (typeof nextFetchPolicy === 'string') {
return nextFetchPolicy;
}
if (nextFetchPolicy) {
return nextFetchPolicy(lastPolicy);
}
return 'cache-first';
};
const useQueryWrapped = <TData = any, TVariables = OperationVariables>(
query: DocumentNode,
options: QueryHookOptions<TData, TVariables>,
) => {
const client = useApolloClient();
const defaultOptions = client.defaultOptions;
const currentCachePolicy = options?.fetchPolicy ?? defaultOptions.watchQuery?.fetchPolicy;
const nextFetchPolicy =
options?.nextFetchPolicy ?? getNextFetchPolicy(currentCachePolicy, defaultOptions.watchQuery?.nextFetchPolicy);
const persistFetchPolicyState = options.persistFetchPolicyState || defaultOptions.watchQuery.persistFetchPolicyState;
const uniqueKey = useMemo(
() => (persistFetchPolicyState ? `${JSON.stringify(query)}-${JSON.stringify(options.variables)}` : ''),
[query, options.variables, persistNextFetchPolicyAcrossMounts],
);
const firstFetchDone = firstFetchCompleteMap.get(uniqueKey);
if (persistFetchPolicyState && firstFetchDone) {
Object.assign(options, { fetchPolicy: nextFetchPolicy });
}
const response = apolloUseQuery(query, options);
const previousLoading = usePrevious(response.loading);
useEffect(() => {
if (previousLoading === true && response.loading === false && !firstFetchDone) {
firstFetchCompleteMap.set(uniqueKey, true);
}
}, [response.loading, uniqueKey, firstFetchDone, previousLoading]);
return response;
};
Proposed solution
Apollo could support persistFetchPolicyState at the default option level, as well as individual queries:
const apolloClient = new WrappedApolloClient({
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
persistFetchPolicyState: true,
}
}
...
});
In this scenario, Apollo could compute identity by default using the query and variables. Otherwise, a user could also define it. themselves with an optional function that accepts query and queryOptions, and the user can return dependencies that Apollo would serialize (or the user can serialize themselves).
We can also do it from a query level, which would allow consumers to specify better identity functions that are more performant than serializing everything:
const MyComponent = (props) => {
const { id } = props;
const { data, loading, error } = useQuery(MY_QUERY, {
variables: { id },
// Could also be a boolean to use the default identity of query + variables
persistFetchPolicyState: () => `MyComponentQuery_${id}`,
}
return <div>...</div>
};
Although fetchPolicy was what motivated this FR, I imagine this functionality would go further than just preserving options.fetchPolicy, allowing the entire QueryData object to be preserved without relying on useRef and queryDataRef.
If done right, this functionality would be especially transformative because it's tricky to preserve any data across unmount/remount when using React: https://stackoverflow.com/questions/31352261/how-to-keep-react-component-state-between-mount-unmount
cc @hwillson @jcreighton
@benjamn yeah I was thinking about that too -- if Apollo ends up going down this route, though, it may be worthwhile to think about how to reduce the cost of identity generation, or, as you had suggested, allowing components to define a unique identity when calling useQuery, and if none is provided, fall back to using the more expensive generation from a component's query and variables.
@benjamn I just realized a gotcha with this approach:
-
I execute a mutation, and it calls refetchQueries, and one of the queries is currently unmounted, so it won't actually execute again.
-
if I return to a page which re-mounts that query, and I'm now persisting the fetch policy state, that query won't re-execute if the "nextFetchPolicy" is cache-first or cache-and-network. Before, you'd get that re-execution "accidentally" in the sense that it's now reverted to the original fetch policy state which didn't read from cache.
In fact, I think this is maybe an issue with how refetchQueries works, in that it doesn't queue up refetches for unmounted queries? It seems like unmounted queries should honor any refetches which were specified by mutations that happened while it was unmounted.