Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Leave fetchPolicy unchanged when skip: true (or in standby) and nextFetchPolicy is available #9823

Merged
merged 7 commits into from
Jun 19, 2022
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## Apollo Client 3.6.9 (unreleased)

### Bug Fixes

- Leave `fetchPolicy` unchanged when `skip: true` (or in standby) and `nextFetchPolicy` is available, even if `variables` change. <br/>
[@benjamn](https://github.com/benjamn) in [#9823](https://github.com/apollographql/apollo-client/pull/9823)

## Apollo Client 3.6.8 (2022-06-10)

### Bug Fixes
Expand Down
31 changes: 18 additions & 13 deletions src/core/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,18 +653,19 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`);
initialFetchPolicy = fetchPolicy,
} = options;

// When someone chooses "cache-and-network" or "network-only" as their
// initial FetchPolicy, they often do not want future cache updates to
// trigger unconditional network requests, which is what repeatedly
// applying the "cache-and-network" or "network-only" policies would seem
// to imply. Instead, when the cache reports an update after the initial
// network request, it may be desirable for subsequent network requests to
// be triggered only if the cache result is incomplete. To that end, the
// options.nextFetchPolicy option provides an easy way to update
// options.fetchPolicy after the initial network request, without having to
// call observableQuery.setOptions.

if (typeof options.nextFetchPolicy === "function") {
if (fetchPolicy === "standby") {
// Do nothing, leaving options.fetchPolicy unchanged.
Comment on lines +656 to +657
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not only do we avoid calling applyNextFetchPolicy when options.fetchPolicy === "standby", but this empty conditional block ensures applyNextFetchPolicy has no effect in that case. Defense in depth!

} else if (typeof options.nextFetchPolicy === "function") {
// When someone chooses "cache-and-network" or "network-only" as their
// initial FetchPolicy, they often do not want future cache updates to
// trigger unconditional network requests, which is what repeatedly
// applying the "cache-and-network" or "network-only" policies would
// seem to imply. Instead, when the cache reports an update after the
// initial network request, it may be desirable for subsequent network
// requests to be triggered only if the cache result is incomplete. To
// that end, the options.nextFetchPolicy option provides an easy way to
// update options.fetchPolicy after the initial network request, without
// having to call observableQuery.setOptions.
options.fetchPolicy = options.nextFetchPolicy(fetchPolicy, {
reason,
options,
Expand Down Expand Up @@ -809,7 +810,11 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`);
newOptions &&
newOptions.variables &&
!equal(newOptions.variables, oldVariables) &&
(!newOptions.fetchPolicy || newOptions.fetchPolicy === oldFetchPolicy)
// Don't mess with the fetchPolicy if it's currently "standby".
options.fetchPolicy !== "standby" &&
// If we're changing the fetchPolicy anyway, don't try to change it here
// using applyNextFetchPolicy. The explicit options.fetchPolicy wins.
options.fetchPolicy === oldFetchPolicy
) {
this.applyNextFetchPolicy("variables-changed", options);
if (newNetworkStatus === void 0) {
Expand Down
31 changes: 21 additions & 10 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
asyncMap,
isNonEmptyArray,
Concast,
ConcastSourcesIterable,
ConcastSourcesArray,
makeUniqueId,
isDocumentNode,
isNonNullObject,
Expand Down Expand Up @@ -1122,16 +1122,33 @@ export class QueryManager<TStore> {
// modify its properties here, rather than creating yet another new
// WatchQueryOptions object.
normalized.variables = variables;
return this.fetchQueryByPolicy<TData, TVars>(

const concastSources = this.fetchQueryByPolicy<TData, TVars>(
queryInfo,
normalized,
networkStatus,
);

if (
// If we're in standby, postpone advancing options.fetchPolicy using
// applyNextFetchPolicy.
normalized.fetchPolicy !== "standby" &&
// The "standby" policy currently returns [] from fetchQueryByPolicy, so
// this is another way to detect when nothing was done/fetched.
concastSources.length > 0 &&
queryInfo.observableQuery
) {
queryInfo.observableQuery["applyNextFetchPolicy"]("after-fetch", options);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The applyNextFetchPolicy method is now called synchronously, immediately after the fetch policy was used to make a request (by calling fetchQueryByPolicy above). This required some test changes where we previously assumed the fetchPolicy should still be the same immediately after performing some async operation like refetching, but I strengthened several of those tests to show that the original fetchPolicy is what's used when fetching. I consider this change an improvement since it eliminates the possibility of more than one query using the original fetchPolicy before nextFetchPolicy is applied.

}

return concastSources;
};

// This cancel function needs to be set before the concast is created,
// in case concast creation synchronously cancels the request.
const cleanupCancelFn = () => this.fetchCancelFns.delete(queryId);
this.fetchCancelFns.set(queryId, reason => {
cleanupCancelFn();
// This delay ensures the concast variable has been initialized.
setTimeout(() => concast.cancel(reason));
});
Expand All @@ -1156,13 +1173,7 @@ export class QueryManager<TStore> {
: fromVariables(normalized.variables!)
);

concast.cleanup(() => {
this.fetchCancelFns.delete(queryId);

if (queryInfo.observableQuery) {
queryInfo.observableQuery["applyNextFetchPolicy"]("after-fetch", options);
}
});
concast.promise.then(cleanupCancelFn, cleanupCancelFn);
Comment on lines -1159 to +1176
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In PR #9718, which has only landed on the release-3.7 branch so far, we replaced concast.cleanup with an improved (internal) API called concast.beforeNext. However, if we can just stop using concast.cleanup, that would be another solution to the problems it has caused. There's one more spot where we rely on it, in getObservableFromLink.


return concast;
}
Expand Down Expand Up @@ -1338,7 +1349,7 @@ export class QueryManager<TStore> {
// NetworkStatus.loading, but also possibly fetchMore, poll, refetch,
// or setVariables.
networkStatus: NetworkStatus,
): ConcastSourcesIterable<ApolloQueryResult<TData>> {
): ConcastSourcesArray<ApolloQueryResult<TData>> {
const oldNetworkStatus = queryInfo.networkStatus;

queryInfo.init({
Expand Down
30 changes: 26 additions & 4 deletions src/core/__tests__/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import gql from 'graphql-tag';
import { GraphQLError } from 'graphql';
import { TypedDocumentNode } from '@graphql-typed-document-node/core';

import { ApolloClient, NetworkStatus } from '../../core';
import {
ApolloClient,
NetworkStatus,
WatchQueryFetchPolicy,
} from '../../core';
import { ObservableQuery } from '../ObservableQuery';
import { QueryManager } from '../QueryManager';

Expand Down Expand Up @@ -1190,11 +1194,21 @@ describe('ObservableQuery', () => {
},
);

const usedFetchPolicies: WatchQueryFetchPolicy[] = [];
const observable = queryManager.watchQuery({
query: queryWithVars,
variables: variables1,
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
nextFetchPolicy(currentFetchPolicy, info) {
if (info.reason === "variables-changed") {
return info.initialFetchPolicy;
}
usedFetchPolicies.push(currentFetchPolicy);
if (info.reason === "after-fetch") {
return "cache-first";
}
return currentFetchPolicy;
},
notifyOnNetworkStatusChange: true,
});

Expand Down Expand Up @@ -1222,7 +1236,7 @@ describe('ObservableQuery', () => {
}).then(result => {
expect(result.data).toEqual(data);
}).catch(reject);
expect(observable.options.fetchPolicy).toBe('cache-and-network');
expect(observable.options.fetchPolicy).toBe('cache-first');
} else if (handleCount === 4) {
expect(result.loading).toBe(true);
expect(result.networkStatus).toBe(NetworkStatus.setVariables);
Expand All @@ -1236,7 +1250,7 @@ describe('ObservableQuery', () => {
}).then(result => {
expect(result.data).toEqual(data2);
}).catch(reject);
expect(observable.options.fetchPolicy).toBe('cache-and-network');
expect(observable.options.fetchPolicy).toBe('cache-first');
} else if (handleCount === 6) {
expect(result.data).toEqual(data2);
expect(result.loading).toBe(true);
Expand All @@ -1245,6 +1259,14 @@ describe('ObservableQuery', () => {
expect(result.data).toEqual(data2);
expect(result.loading).toBe(false);
expect(observable.options.fetchPolicy).toBe('cache-first');

expect(usedFetchPolicies).toEqual([
"cache-and-network",
"network-only",
"cache-and-network",
"cache-and-network",
]);

setTimeout(resolve, 10);
} else {
reject(`too many renders (${handleCount})`);
Expand Down
2 changes: 1 addition & 1 deletion src/core/__tests__/fetchPolicies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@ describe("nextFetchPolicy", () => {
}).catch(reject);

// Changing variables resets the fetchPolicy to its initial value.
expect(observable.options.fetchPolicy).toBe("network-only");
expect(observable.options.fetchPolicy).toBe("cache-first");

} else if (count === 3) {
expect(result.loading).toBe(false);
Expand Down
47 changes: 34 additions & 13 deletions src/react/hoc/__tests__/queries/loading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import gql from 'graphql-tag';
import { DocumentNode } from 'graphql';

import { ApolloClient, NetworkStatus } from '../../../../core';
import { ApolloClient, NetworkStatus, WatchQueryFetchPolicy } from '../../../../core';
import { ApolloProvider } from '../../../context';
import { InMemoryCache as Cache } from '../../../../cache';
import { itAsync, mockSingleLink } from '../../../../testing';
Expand Down Expand Up @@ -360,32 +360,46 @@ describe('[queries] loading', () => {

let count = 0;

const usedFetchPolicies: WatchQueryFetchPolicy[] = [];
const Container = graphql<{}, Data>(query, {
options: {
fetchPolicy: 'network-only',
nextFetchPolicy: 'cache-first',
nextFetchPolicy(currentFetchPolicy, info) {
if (info.reason === "variables-changed") {
return info.initialFetchPolicy;
}
usedFetchPolicies.push(currentFetchPolicy);
if (info.reason === "after-fetch") {
return "cache-first";
}
return currentFetchPolicy;
}
},
})(
class extends React.Component<ChildProps<{}, Data>> {
render() {
++count;
if (count === 1) {
expect(this.props.data!.loading).toBe(true);
expect(this.props.data!.allPeople).toBeUndefined();
} else if (count === 2) {
expect(this.props.data!.loading).toBe(false);
expect(this.props.data!.allPeople!.people[0].name).toMatch(
/Darth Skywalker - /
);
// Has data
setTimeout(() => {
render(App);
}, 0);
}
if (count === 2) {
setTimeout(() => render(App));
} else if (count === 3) {
// Loading after remount
expect(this.props.data!.loading).toBeTruthy();
}
if (count === 3) {
expect(this.props.data!.loading).toBe(true);
expect(this.props.data!.allPeople).toBeUndefined();
} else if (count >= 4) {
// Fetched data loading after remount
expect(this.props.data!.loading).toBeFalsy();
expect(this.props.data!.loading).toBe(false);
expect(this.props.data!.allPeople!.people[0].name).toMatch(
/Darth Skywalker - /
);
}
count += 1;
return null;
}
}
Expand All @@ -399,7 +413,14 @@ describe('[queries] loading', () => {

render(App);

waitFor(() => expect(count).toBe(5)).then(resolve, reject);
return waitFor(() => {
expect(usedFetchPolicies).toEqual([
"network-only",
"network-only",
"cache-first",
]);
expect(count).toBe(6);
}).then(resolve, reject);
});

itAsync('correctly sets loading state on remounted component with changed variables', (resolve, reject) => {
Expand Down
2 changes: 1 addition & 1 deletion src/react/hoc/__tests__/queries/skip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ describe('[queries] skip', () => {
break;
case 4:
expect(this.props.skip).toBe(false);
expect(this.props.data!.loading).toBe(true);
expect(this.props.data!.loading).toBe(false);
expect(this.props.data.allPeople).toEqual(data.allPeople);
expect(ranQuery).toBe(2);
break;
Expand Down
4 changes: 2 additions & 2 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,7 @@ describe('useQuery Hook', () => {

expect(
result.current.observable.options.fetchPolicy
).toBe("network-only");
).toBe("cache-first");

expect(result.current.observable.variables).toEqual({
sourceOfVar: "reobserve",
Expand Down Expand Up @@ -1094,7 +1094,7 @@ describe('useQuery Hook', () => {

expect(
result.current.observable.options.fetchPolicy
).toBe("network-only");
).toBe("cache-first");

expect(result.current.observable.variables).toEqual({
sourceOfVar: "reobserve without variable merge",
Expand Down
1 change: 1 addition & 0 deletions src/utilities/observables/Concast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function isPromiseLike<T>(value: MaybeAsync<T>): value is PromiseLike<T> {
type Source<T> = MaybeAsync<Observable<T>>;

export type ConcastSourcesIterable<T> = Iterable<Source<T>>;
export type ConcastSourcesArray<T> = Array<Source<T>>;

// A Concast<T> observable concatenates the given sources into a single
// non-overlapping sequence of Ts, automatically unwrapping any promises,
Expand Down