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

Fix some polling/StrictMode issues, along with some general refactorings #8414

Merged
merged 19 commits into from
Jun 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
- `InMemoryCache` now coalesces `EntityStore` updates to guarantee only one `store.merge(id, fields)` call per `id` per cache write. <br/>
[@benjamn](https://github.com/benjamn) in [#8372](https://github.com/apollographql/apollo-client/pull/8372)

- Fix polling when used with `React.StrictMode`, <br/>
[@brainkim](https://github.com/brainkim) in [#8414](https://github.com/apollographql/apollo-client/pull/8414)

### Potentially disruptive changes

- To avoid retaining sensitive information from mutation root field arguments, Apollo Client v3.4 automatically clears any `ROOT_MUTATION` fields from the cache after each mutation finishes. If you need this information to remain in the cache, you can prevent the removal by passing the `keepRootFields: true` option to `client.mutate`. `ROOT_MUTATION` result data are also passed to the mutation `update` function, so we recommend obtaining the results that way, rather than using `keepRootFields: true`, if possible. <br/>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.cjs.min.js",
"maxSize": "24.7 kB"
"maxSize": "24.35 kB"
}
],
"peerDependencies": {
Expand Down
251 changes: 156 additions & 95 deletions src/core/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { equal } from '@wry/equality';

import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus';
import {
Concast,
compact,
cloneDeep,
getOperationDefinition,
Observable,
Expand All @@ -20,7 +22,6 @@ import {
FetchMoreQueryOptions,
SubscribeToMoreOptions,
} from './watchQueryOptions';
import { Reobserver } from './Reobserver';
import { QueryInfo } from './QueryInfo';

export interface FetchMoreOptions<
Expand Down Expand Up @@ -66,6 +67,12 @@ export class ObservableQuery<
private lastError: ApolloError | undefined;
private queryInfo: QueryInfo;

private concast?: Concast<ApolloQueryResult<TData>>;
private pollingInfo?: {
interval: number;
timeout: ReturnType<typeof setTimeout>;
};

constructor({
queryManager,
queryInfo,
Expand All @@ -75,9 +82,42 @@ export class ObservableQuery<
queryInfo: QueryInfo;
options: WatchQueryOptions<TVariables, TData>;
}) {
super((observer: Observer<ApolloQueryResult<TData>>) =>
this.onSubscribe(observer),
);
super((observer: Observer<ApolloQueryResult<TData>>) => {
// Zen Observable has its own error function, so in order to log correctly
// we need to provide a custom error callback.
try {
var subObserver = (observer as any)._subscription._observer;
if (subObserver && !subObserver.error) {
subObserver.error = defaultSubscriptionObserverErrorCallback;
}
} catch {}

const first = !this.observers.size;
this.observers.add(observer);

// Deliver most recent error or result.
if (this.lastError) {
observer.error && observer.error(this.lastError);
} else if (this.lastResult) {
observer.next && observer.next(this.lastResult);
}

// Initiate observation of this query if it hasn't been reported to
// the QueryManager yet.
if (first) {
// Blindly catching here prevents unhandled promise rejections,
// and is safe because the ObservableQuery handles this error with
// this.observer.error, so we're not just swallowing the error by
// ignoring it here.
this.reobserve().catch(() => {});
}

return () => {
if (this.observers.delete(observer) && !this.observers.size) {
this.tearDownQuery();
}
};
});

// active state
this.isTornDown = false;
Expand All @@ -91,12 +131,14 @@ export class ObservableQuery<

// related classes
this.queryManager = queryManager;

this.queryInfo = queryInfo;
}

public result(): Promise<ApolloQueryResult<TData>> {
return new Promise((resolve, reject) => {
// TODO: this code doesn’t actually make sense insofar as the observer
// will never exist in this.observers due how zen-observable wraps observables.
// https://github.com/zenparsing/zen-observable/blob/master/src/Observable.js#L169
brainkim marked this conversation as resolved.
Show resolved Hide resolved
const observer: Observer<ApolloQueryResult<TData>> = {
next: (result: ApolloQueryResult<TData>) => {
resolve(result);
Expand Down Expand Up @@ -244,8 +286,9 @@ export class ObservableQuery<
// (no-cache, network-only, or cache-and-network), override it with
// network-only to force the refetch for this fetchQuery call.
const { fetchPolicy } = this.options;
if (fetchPolicy !== 'no-cache' &&
fetchPolicy !== 'cache-and-network') {
if (fetchPolicy === 'no-cache') {
reobserveOptions.fetchPolicy = 'no-cache';
} else if (fetchPolicy !== 'cache-and-network') {
reobserveOptions.fetchPolicy = 'network-only';
// Go back to the original options.fetchPolicy after this refetch.
reobserveOptions.nextFetchPolicy = fetchPolicy || "cache-first";
Expand All @@ -260,11 +303,7 @@ export class ObservableQuery<
}

this.queryInfo.resetLastWrite();

return this.newReobserver(false).reobserve(
reobserveOptions,
NetworkStatus.refetch,
);
return this.reobserve(reobserveOptions, NetworkStatus.refetch);
}

public fetchMore(
Expand Down Expand Up @@ -493,111 +532,136 @@ once, rather than every time you call fetchMore.`);
}

public startPolling(pollInterval: number) {
this.getReobserver().updateOptions({ pollInterval });
this.options.pollInterval = pollInterval;
this.updatePolling();
}

public stopPolling() {
if (this.reobserver) {
this.reobserver.updateOptions({ pollInterval: 0 });
}
this.options.pollInterval = 0;
this.updatePolling();
}

private updateLastResult(newResult: ApolloQueryResult<TData>) {
const previousResult = this.lastResult;
this.lastResult = newResult;
this.lastResultSnapshot = this.queryManager.assumeImmutableResults
? newResult
: cloneDeep(newResult);
if (!isNonEmptyArray(newResult.errors)) {
delete this.lastError;
}
return previousResult;
private fetch(
options: WatchQueryOptions<TVariables, TData>,
newNetworkStatus?: NetworkStatus,
): Concast<ApolloQueryResult<TData>> {
this.queryManager.setObservableQuery(this);
return this.queryManager.fetchQueryObservable(
this.queryId,
options,
newNetworkStatus,
);
}

private onSubscribe(observer: Observer<ApolloQueryResult<TData>>) {
// Subscribing using this.observer will create an infinite notificaion
// loop, but the intent of broadcasting results to all the other
// this.observers can be satisfied without doing anything, which is
// why we do not bother throwing an error here.
if (observer === this.observer) {
return () => {};
// Turns polling on or off based on this.options.pollInterval.
private updatePolling() {
// Avoid polling in SSR mode
if (this.queryManager.ssrMode) {
return;
}

// Zen Observable has its own error function, so in order to log correctly
// we need to provide a custom error callback.
try {
var subObserver = (observer as any)._subscription._observer;
if (subObserver && !subObserver.error) {
subObserver.error = defaultSubscriptionObserverErrorCallback;
}
} catch {}

const first = !this.observers.size;
this.observers.add(observer);
const {
pollingInfo,
options: {
pollInterval,
},
} = this;

// Deliver most recent error or result.
if (this.lastError) {
observer.error && observer.error(this.lastError);
} else if (this.lastResult) {
observer.next && observer.next(this.lastResult);
if (!pollInterval) {
if (pollingInfo) {
clearTimeout(pollingInfo.timeout);
delete this.pollingInfo;
}
return;
}

// Initiate observation of this query if it hasn't been reported to
// the QueryManager yet.
if (first) {
// Blindly catching here prevents unhandled promise rejections,
// and is safe because the ObservableQuery handles this error with
// this.observer.error, so we're not just swallowing the error by
// ignoring it here.
this.reobserve().catch(() => {});
if (pollingInfo &&
pollingInfo.interval === pollInterval) {
return;
}

return () => {
if (this.observers.delete(observer) && !this.observers.size) {
this.tearDownQuery();
}
invariant(
pollInterval,
'Attempted to start a polling query without a polling interval.',
);

const info = pollingInfo || (this.pollingInfo = {} as any);
info.interval = pollInterval;

const maybeFetch = () => {
if (this.pollingInfo) {
if (!isNetworkRequestInFlight(this.queryInfo.networkStatus)) {
this.reobserve({
fetchPolicy: "network-only",
nextFetchPolicy: this.options.fetchPolicy || "cache-first",
}, NetworkStatus.poll).then(poll, poll);
} else {
poll();
}
};
};
}

private reobserver?: Reobserver<TData, TVariables>;
const poll = () => {
const info = this.pollingInfo;
if (info) {
clearTimeout(info.timeout);
info.timeout = setTimeout(maybeFetch, info.interval);
}
};

private getReobserver(): Reobserver<TData, TVariables> {
return this.reobserver || (this.reobserver = this.newReobserver(true));
poll();
}

private newReobserver(shareOptions: boolean) {
const { queryManager, queryId } = this;
queryManager.setObservableQuery(this);
return new Reobserver<TData, TVariables>(
this.observer,
// Sharing options allows this.reobserver.options to be ===
// this.options, so we don't have to worry about synchronizing the
// properties of two distinct objects.
shareOptions ? this.options : { ...this.options },
(currentOptions, newNetworkStatus) => {
queryManager.setObservableQuery(this);
return queryManager.fetchQueryObservable(
queryId,
currentOptions,
newNetworkStatus,
);
},
// Avoid polling during SSR and when the query is already in flight.
!queryManager.ssrMode && (
() => !isNetworkRequestInFlight(this.queryInfo.networkStatus))
);
private updateLastResult(newResult: ApolloQueryResult<TData>) {
const previousResult = this.lastResult;
this.lastResult = newResult;
this.lastResultSnapshot = this.queryManager.assumeImmutableResults
? newResult
: cloneDeep(newResult);
if (!isNonEmptyArray(newResult.errors)) {
delete this.lastError;
}
return previousResult;
}

public reobserve(
newOptions?: Partial<WatchQueryOptions<TVariables, TData>>,
newNetworkStatus?: NetworkStatus,
): Promise<ApolloQueryResult<TData>> {
this.isTornDown = false;
return this.getReobserver().reobserve(newOptions, newNetworkStatus);
let options: WatchQueryOptions<TVariables, TData>;
if (newNetworkStatus === NetworkStatus.refetch) {
options = Object.assign({}, this.options, compact(newOptions));
} else {
if (newOptions) {
Object.assign(this.options, compact(newOptions));
}

this.updatePolling();
options = this.options;
}

const concast = this.fetch(options, newNetworkStatus);
if (newNetworkStatus !== NetworkStatus.refetch) {
// We use the {add,remove}Observer methods directly to avoid
// wrapping observer with an unnecessary SubscriptionObserver
// object, in part so that we can remove it here without triggering
// any unsubscriptions, because we just want to ignore the old
// observable, not prematurely shut it down, since other consumers
// may be awaiting this.concast.promise.
if (this.concast) {
this.concast.removeObserver(this.observer, true);
}

this.concast = concast;
}

concast.addObserver(this.observer);
return concast.promise;
}

// Pass the current result to this.observer.next without applying any
// fetch policies, bypassing the Reobserver.
// fetch policies.
private observe() {
// Passing false is important so that this.getCurrentResult doesn't
// save the fetchMore result as this.lastResult, causing it to be
Expand Down Expand Up @@ -635,20 +699,17 @@ once, rather than every time you call fetchMore.`);

private tearDownQuery() {
if (this.isTornDown) return;

if (this.reobserver) {
this.reobserver.stop();
delete this.reobserver;
if (this.concast) {
this.concast.removeObserver(this.observer);
delete this.concast;
}

this.stopPolling();
// stop all active GraphQL subscriptions
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions.clear();

this.queryManager.stopQuery(this.queryId);

this.observers.clear();

this.isTornDown = true;
}
}
Expand Down
Loading